If you are still debugging production issues by grepping through a massive text file named error_log or, worse, waiting for a user to send you a screenshot of a “Whoops, something went wrong” page, this article is for you.
In the landscape of 2025, observability is not a luxury; it is a requirement. As PHP applications grow into complex microservices or monolithic giants, the default PHP error handling mechanisms simply don’t cut it. You need context. You need stack traces that make sense. You need to know who triggered the error, what input they provided, and why the database rejected it.
In this guide, we are going to build a production-ready Exception Handling system from scratch. We will move beyond simple try-catch blocks and implement a global safety net that captures everything—from fatal errors to uncaught exceptions—and logs them as structured JSON data suitable for modern monitoring stacks (like ELK, Datadog, or CloudWatch).
Prerequisites and Environment #
To follow along effectively, you should have the following:
- PHP 8.2 or 8.3: We will utilize modern features like typed properties and readonly classes.
- Composer: Standard dependency management.
- A Local Development Environment: Docker, XAMPP, or Valet.
We will use Monolog, the industry standard for PHP logging, to handle the heavy lifting of writing logs.
Setting Up the Project #
First, create a directory for your project and initialize Composer.
mkdir php-error-handling
cd php-error-handling
composer init --name="phpdevpro/error-handling" --require="php:^8.2" -n
composer require monolog/monologCreate the following folder structure:
/src
/Exceptions
/Handlers
/Logs
index.php
vendor/Update your composer.json to enable autoloading for the App namespace:
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"require": {
"monolog/monolog": "^3.0"
}
}Run composer dump-autoload to register the namespace.
The Mental Model: Errors vs. Exceptions #
Before writing code, we must understand the flow. In modern PHP, almost everything that goes wrong implements the Throwable interface. However, legacy “Errors” (like using an undefined variable) and “Exceptions” (logic errors) behave slightly differently.
Our goal is to unify them. We want to convert all Errors into Exceptions so they can be handled by a single, centralized logic flow.
Here is the flow we are going to build:
Step 1: Defining Custom Exceptions #
Using the generic \Exception class is a bad habit. It tells you that an error occurred, but not what kind. By extending the base Exception class, we can catch specific scenarios and attach context.
Let’s create a base exception class that allows us to pass extra debugging data (payloads) without messing up the error message string.
Create src/Exceptions/AppException.php:
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class AppException extends Exception
{
/**
* @param string $message The error message
* @param array $context Additional data to help debug (e.g., user_id, input_data)
* @param int $code Error code
* @param Throwable|null $previous Previous exception for chaining
*/
public function __construct(
string $message = "",
private readonly array $context = [],
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
public function getContext(): array
{
return $this->context;
}
}Now, let’s create a specific exception, for example, ResourceNotFoundException.
Create src/Exceptions/ResourceNotFoundException.php:
<?php
namespace App\Exceptions;
class ResourceNotFoundException extends AppException
{
protected $message = 'The requested resource could not be found.';
protected $code = 404;
}Why this matters: When you look at your logs later, you won’t just see “Not Found”. You will access the $context array to see exactly which ID was requested.
Step 2: Configuring Monolog #
We need a logger instance. In a real framework (Laravel/Symfony), this is injected via a Service Container. For this vanilla PHP example, we will create a simple factory.
Create src/LoggerService.php:
<?php
namespace App;
use Monolog\Level;
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Formatter\JsonFormatter;
class LoggerService
{
public static function getLogger(): Logger
{
$logger = new Logger('app_logger');
// Create a handler (save to file)
$stream = new StreamHandler(__DIR__ . '/../logs/app.log', Level::Debug);
// USE JSON FORMATTER: This is critical for modern observability
$formatter = new JsonFormatter();
$stream->setFormatter($formatter);
$logger->pushHandler($stream);
return $logger;
}
}Pro Tip: Structured Logging #
Always use JsonFormatter in production. Plain text logs are hard for machines to parse. JSON logs can be ingested immediately by tools like Logstash or Datadog, allowing you to filter by specific fields (e.g., context.user_id = 42).
Step 3: The Global Error Handler #
This is the core of our system. We will create a class that registers itself as the handler for:
- Exceptions (
set_exception_handler) - Errors (
set_error_handler) - Shutdowns (
register_shutdown_function) - to catch “Fatal Errors” like memory exhaustion.
Create src/Handlers/ErrorHandler.php:
<?php
namespace App\Handlers;
use Throwable;
use ErrorException;
use Psr\Log\LoggerInterface;
use App\Exceptions\AppException;
class ErrorHandler
{
public function __construct(
private LoggerInterface $logger,
private string $environment = 'production'
) {}
public function register(): void
{
// 1. Handle Uncaught Exceptions
set_exception_handler([$this, 'handleException']);
// 2. Handle PHP Errors (Warnings, Notices) -> Convert to Exception
set_error_handler([$this, 'handleError']);
// 3. Handle Fatal Shutdowns
register_shutdown_function([$this, 'handleShutdown']);
}
public function handleError(int $severity, string $message, string $file, int $line): bool
{
if (!(error_reporting() & $severity)) {
// This error code is not included in error_reporting
return false;
}
// Convert all errors to ErrorException so they can be handled by handleException
throw new ErrorException($message, 0, $severity, $file, $line);
}
public function handleShutdown(): void
{
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE])) {
$this->handleException(
new ErrorException($error['message'], 0, $error['type'], $error['file'], $error['line'])
);
}
}
public function handleException(Throwable $exception): void
{
// 1. Build the log context
$context = [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
];
// If it's our custom exception, add specific context data
if ($exception instanceof AppException) {
$context['extra'] = $exception->getContext();
}
// 2. Log it
$this->logger->error($exception->getMessage(), $context);
// 3. Render response to user
$this->renderResponse($exception);
}
private function renderResponse(Throwable $exception): void
{
// Clean output buffer to remove any partial HTML rendered before error
if (ob_get_length()) {
ob_clean();
}
http_response_code(500);
if ($exception instanceof AppException && $exception->getCode() >= 400 && $exception->getCode() < 600) {
http_response_code($exception->getCode());
}
header('Content-Type: application/json');
$response = [
'status' => 'error',
'message' => 'Internal Server Error',
];
// In Development, show everything. In Production, hide details.
if ($this->environment === 'development') {
$response['message'] = $exception->getMessage();
$response['debug'] = [
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => explode("\n", $exception->getTraceAsString())
];
}
echo json_encode($response);
exit;
}
}Step 4: Putting It All Together #
Now, let’s create our entry point index.php to simulate the application lifecycle.
<?php
require_once __DIR__ . '/vendor/autoload.php';
use App\LoggerService;
use App\Handlers\ErrorHandler;
use App\Exceptions\ResourceNotFoundException;
// 1. Initialize Logger
$logger = LoggerService::getLogger();
// 2. Register Error Handler (Set environment to 'development' to see traces)
$handler = new ErrorHandler($logger, 'development');
$handler->register();
// 3. Simulate Application Logic
// Scenario A: Trigger a standard PHP Warning (will be converted to exception)
// echo $undefinedVariable;
// Scenario B: Throw a custom exception with context
$userId = 450;
if (true) { // Simulating a "Not Found" condition
throw new ResourceNotFoundException(
"User with ID $userId not found in database.",
['user_id' => $userId, 'ip' => $_SERVER['REMOTE_ADDR'] ?? '127.0.0.1']
);
}
// Scenario C: Happy Path
echo json_encode(['status' => 'success']);Running the Code #
- Run the server:
php -S localhost:8000 - Visit
http://localhost:8000 - Because of Scenario B, you will see a JSON error response.
- Check
logs/app.log. You will see a beautiful JSON entry containing the error and the context (user_id).
Log Levels: When to Use What? #
One of the biggest mistakes developers make is logging everything as ERROR. This leads to “alert fatigue”—when everything is urgent, nothing is urgent.
Here is a breakdown of how you should map PHP scenarios to Monolog levels:
| Log Level | PHP Context | Action Required |
|---|---|---|
| DEBUG | Variable dumps, query logs, flow confirmation. | None. Used during development only. |
| INFO | “User logged in”, “Job started”, “Payment processed”. | None. Useful for analytics/audit trails. |
| WARNING | Deprecated function usage, disk space running low, API rate limit nearing. | Investigate eventually. System is still working. |
| ERROR | Uncaught Exception, database connection failed, API 500 response. |
Immediate investigation. A specific feature failed. |
| CRITICAL | Application unavailable, core service down. | Wake up the DevOps team. |
Best Practices and Common Pitfalls #
1. Never Log Sensitive Data (GDPR/PII) #
This is the most dangerous trap. If you dump $_POST into your logs during a login exception, you might log user passwords in plain text.
Solution: Implement a “Redactor” or sanitizer in your handler before sending context to Monolog.
private function sanitizeContext(array $context): array
{
$sensitiveKeys = ['password', 'credit_card', 'api_key', 'secret'];
foreach ($context as $key => $value) {
if (in_array($key, $sensitiveKeys)) {
$context[$key] = '********';
}
}
return $context;
}2. Don’t Swallowing Exceptions #
“Swallowing” happens when you catch an exception and do nothing with it.
Bad:
try {
$db->connect();
} catch (Exception $e) {
// I'll fix this later
}Good:
try {
$db->connect();
} catch (Exception $e) {
$logger->error('DB Connection Failed', ['error' => $e->getMessage()]);
throw $e; // Re-throw if you can't recover!
}3. Turning PHP Errors into Exceptions #
We handled this in our ErrorHandler class using set_error_handler. This is crucial because standard PHP notices (like accessing an array key that doesn’t exist) often continue execution but leave the application in an unstable state. By converting them to ErrorException, we force the application to fail fast and loudly, preventing data corruption.
Conclusion #
By implementing a centralized ErrorHandler and leveraging Monolog with JSON formatting, you have transformed your PHP application from a black box into a transparent glass house.
You now have a system that:
- Catches all types of errors (Fatal, Notice, Exception).
- Provides contextual data (User IDs, Input) without cluttering messages.
- Logs in a machine-readable format ready for 2026-era observability tools.
- Keeps sensitive details hidden from the end-user while exposing them to the developer.
Next Steps:
- Integrate this handler with a notification channel (e.g., Slack or Email) for
CRITICALlevel errors using Monolog’sSlackWebhookHandler. - Look into OpenTelemetry for PHP to trace requests across microservices.
Stop letting your users find your bugs. Implement this system today and start coding with confidence.