← Back to DevBytes

Error Handling Patterns in PHP

Understanding Error Handling in PHP

Error handling is the practice of anticipating, detecting, and gracefully managing errors that occur during program execution. In PHP, errors can range from simple notices about undefined variables to fatal runtime exceptions that halt script execution entirely. A robust error handling strategy ensures your application remains stable, secure, and user-friendly even when things go wrong.

What Are PHP Errors and Exceptions?

PHP produces several categories of issues during execution:

Since PHP 7.0, most fatal errors have been converted to exceptions (like TypeError, ParseError, Error), making them catchable for the first time. PHP 8.0 further refined this with the introduction of the ValueError and UnhandledMatchError exception types.

Why Error Handling Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Effective error handling is not merely about preventing crashes — it directly impacts security, user experience, maintainability, and data integrity. Consider these critical reasons:

Basic Error Handling: The Traditional Approach

Before diving into modern patterns, understand the foundational PHP error management functions. These remain relevant for legacy code and low-level error interception.

Setting Error Reporting Levels

Use error_reporting() to control which error levels PHP will emit. In development, you want maximum visibility; in production, you want zero output to the browser.

<?php
// Development: report everything
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');

// Production: log errors, never display them
error_reporting(E_ALL & ~E_DEPRECATED & ~E_STRICT);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/application.log');

Custom Error Handler with set_error_handler()

You can intercept runtime errors (notices, warnings, and user-triggered errors) before they propagate. This allows centralized logging and formatting.

<?php
function customErrorHandler(int $errno, string $errstr, string $errfile, int $errline): bool
{
    $severity = match ($errno) {
        E_NOTICE, E_USER_NOTICE => 'NOTICE',
        E_WARNING, E_USER_WARNING => 'WARNING',
        E_ERROR, E_USER_ERROR => 'ERROR',
        default => 'UNKNOWN'
    };

    $message = sprintf(
        "[%s] %s in %s on line %d",
        $severity, $errstr, $errfile, $errline
    );

    error_log($message);

    // Return true to prevent PHP's default handler from running
    return true;
}

set_error_handler('customErrorHandler');

// Trigger a notice
echo $undefinedVariable; // Now handled by our custom function

Handling Fatal Errors with register_shutdown_function()

For errors that halt execution before reaching your custom handler, register a shutdown function to capture the last error.

<?php
function shutdownHandler(): void
{
    $error = error_get_last();
    
    if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
        $message = sprintf(
            "[FATAL] %s in %s on line %d",
            $error['message'],
            $error['file'],
            $error['line']
        );
        error_log($message);
        
        // Render a friendly error page
        http_response_code(500);
        echo "<h1>Something went wrong</h1>";
        echo "<p>Our team has been notified.</p>";
    }
}

register_shutdown_function('shutdownHandler');

Modern Exception-Based Error Handling

PHP's exception model, powered by the Throwable interface (introduced in PHP 7.0), provides a unified hierarchy for all runtime problems. Understanding this hierarchy is essential for designing effective catch blocks.

The Throwable Hierarchy

Throwable (interface)
├── Error (class, implements Throwable)
│   ├── TypeError
│   ├── ValueError
│   ├── ParseError
│   ├── AssertionError
│   ├── ArithmeticError
│   │   └── DivisionByZeroError
│   └── CompileError
│       └── ParseError
└── Exception (class, implements Throwable)
    ├── LogicException
    │   ├── DomainException
    │   ├── InvalidArgumentException
    │   ├── LengthException
    │   └── OutOfRangeException
    ├── RuntimeException
    │   ├── OverflowException
    │   ├── RangeException
    │   ├── UnderflowException
    │   └── UnexpectedValueException
    └── ... (custom exceptions you create)

The Try-Catch-Finally Pattern

The fundamental building block of exception handling. Use finally for cleanup code that must run regardless of success or failure.

<?php
function readConfigurationFile(string $path): array
{
    if (!file_exists($path)) {
        throw new RuntimeException("Configuration file not found: {$path}");
    }
    
    $content = file_get_contents($path);
    
    if ($content === false) {
        throw new RuntimeException("Unable to read configuration file: {$path}");
    }
    
    $config = json_decode($content, true);
    
    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new RuntimeException(
            "Invalid JSON in configuration: " . json_last_error_msg()
        );
    }
    
    return $config;
}

try {
    $config = readConfigurationFile('/app/config.json');
    echo "Database host: " . $config['database']['host'];
} catch (RuntimeException $e) {
    error_log("Config load failed: " . $e->getMessage());
    // Fall back to default configuration
    $config = ['database' => ['host' => 'localhost']];
    echo "Using default configuration.";
} finally {
    // Always close resources, regardless of outcome
    if (isset($config) && is_resource($config)) {
        fclose($config);
    }
    echo "<br>Configuration loading routine completed.";
}

Nested Try-Catch for Granular Recovery

Different operations may require different recovery strategies. Nesting try-catch blocks allows precise error handling at each layer.

<?php
function processUserOrder(int $userId, array $orderItems): array
{
    try {
        // Outer try: high-level order processing
        try {
            $user = fetchUserFromDatabase($userId);
        } catch (DatabaseConnectionException $e) {
            error_log("DB connection failed, attempting replica: " . $e->getMessage());
            $user = fetchUserFromDatabaseReplica($userId);
        }
        
        foreach ($orderItems as $item) {
            try {
                validateInventory($item['product_id'], $item['quantity']);
            } catch (InsufficientStockException $e) {
                // Notify inventory team but continue with other items
                error_log("Stock shortage: " . $e->getMessage());
                sendInventoryAlert($item['product_id']);
                continue; // Skip this item, process the rest
            }
        }
        
        return createOrderRecord($userId, $orderItems);
        
    } catch (Exception $e) {
        error_log("Order processing failed: " . $e->getMessage());
        throw new OrderProcessingException(
            "Could not complete order for user {$userId}",
            0,
            $e  // Preserve the original exception chain
        );
    }
}

Custom Exception Classes

Creating domain-specific exception classes transforms generic error handling into a rich, semantic communication layer. Each exception type carries precise meaning about what went wrong and where.

Designing an Exception Hierarchy

<?php
// Base exception for your entire application
abstract class ApplicationException extends RuntimeException
{
    protected string $errorCode;
    protected array $context = [];
    
    public function __construct(string $message = "", string $errorCode = "", array $context = [])
    {
        parent::__construct($message);
        $this->errorCode = $errorCode;
        $this->context = $context;
    }
    
    public function getErrorCode(): string
    {
        return $this->errorCode;
    }
    
    public function getContext(): array
    {
        return $this->context;
    }
}

// Domain-specific exceptions
class PaymentException extends ApplicationException
{
    protected string $transactionId;
    
    public function __construct(string $message, string $transactionId, array $context = [])
    {
        parent::__construct($message, 'PAYMENT_FAILED', $context);
        $this->transactionId = $transactionId;
    }
    
    public function getTransactionId(): string
    {
        return $this->transactionId;
    }
}

class ValidationException extends ApplicationException
{
    protected array $validationErrors;
    
    public function __construct(string $message, array $validationErrors = [])
    {
        parent::__construct($message, 'VALIDATION_FAILED');
        $this->validationErrors = $validationErrors;
    }
    
    public function getValidationErrors(): array
    {
        return $this->validationErrors;
    }
}

class RateLimitException extends ApplicationException
{
    protected int $retryAfterSeconds;
    
    public function __construct(int $retryAfterSeconds)
    {
        $this->retryAfterSeconds = $retryAfterSeconds;
        parent::__construct(
            "Rate limit exceeded. Retry after {$retryAfterSeconds} seconds.",
            'RATE_LIMIT'
        );
    }
    
    public function getRetryAfterSeconds(): int
    {
        return $this->retryAfterSeconds;
    }
}

Using Custom Exceptions for Control Flow

<?php
function chargeCustomer(string $paymentMethod, float $amount): PaymentResult
{
    try {
        $gateway = selectPaymentGateway($paymentMethod);
        $result = $gateway->charge($amount);
        
        if (!$result->success) {
            throw new PaymentException(
                "Charge failed: {$result->errorMessage}",
                $result->transactionId,
                ['gateway' => get_class($gateway), 'amount' => $amount]
            );
        }
        
        return $result;
        
    } catch (PaymentException $e) {
        // Log structured payment failure data
        error_log(json_encode([
            'event' => 'payment_failed',
            'error_code' => $e->getErrorCode(),
            'transaction_id' => $e->getTransactionId(),
            'context' => $e->getContext(),
            'timestamp' => date('c')
        ]));
        
        // Notify customer support for high-value transactions
        if ($amount > 500) {
            notifyFraudTeam($e);
        }
        
        throw $e; // Re-throw for upstream handling
    }
}

Advanced Error Handling Patterns

Pattern 1: The Result / Either Monad Pattern

Instead of throwing exceptions for expected failure modes, return a typed result object that forces the caller to handle both success and failure paths explicitly. This pattern, popular in functional programming, eliminates unhandled exception surprises at compile time.

<?php
class Result
{
    private mixed $value;
    private bool $isSuccess;
    private ?string $error;
    
    private function __construct(mixed $value, bool $isSuccess, ?string $error = null)
    {
        $this->value = $value;
        $this->isSuccess = $isSuccess;
        $this->error = $error;
    }
    
    public static function success(mixed $value): self
    {
        return new self($value, true);
    }
    
    public static function failure(string $error): self
    {
        return new self(null, false, $error);
    }
    
    public function isSuccess(): bool
    {
        return $this->isSuccess;
    }
    
    public function isFailure(): bool
    {
        return !$this->isSuccess;
    }
    
    public function getValue(): mixed
    {
        if (!$this->isSuccess) {
            throw new LogicException("Cannot get value from failed result: {$this->error}");
        }
        return $this->value;
    }
    
    public function getError(): ?string
    {
        return $this->error;
    }
    
    /**
     * Transform the success value through a function, preserving failure.
     */
    public function map(callable $fn): self
    {
        if ($this->isFailure()) {
            return $this;
        }
        return self::success($fn($this->value));
    }
    
    /**
     * Chain another Result-producing operation.
     */
    public function flatMap(callable $fn): self
    {
        if ($this->isFailure()) {
            return $this;
        }
        return $fn($this->value);
    }
    
    /**
     * Provide a fallback value on failure.
     */
    public function orElse(mixed $default): mixed
    {
        return $this->isSuccess ? $this->value : $default;
    }
    
    /**
     * Execute a callback on failure for side effects (logging, etc.).
     */
    public function onFailure(callable $fn): self
    {
        if ($this->isFailure()) {
            $fn($this->error);
        }
        return $this;
    }
}

// Usage: parsing user input without exceptions
function parseEmail(string $raw): Result
{
    $trimmed = trim($raw);
    
    if (empty($trimmed)) {
        return Result::failure("Email address is empty");
    }
    
    if (!filter_var($trimmed, FILTER_VALIDATE_EMAIL)) {
        return Result::failure("Invalid email format: {$trimmed}");
    }
    
    return Result::success(strtolower($trimmed));
}

function findUserByEmail(string $email): Result
{
    $stmt = Database::prepare("SELECT * FROM users WHERE email = ?");
    $stmt->execute([$email]);
    $user = $stmt->fetch();
    
    if (!$user) {
        return Result::failure("No user found with email: {$email}");
    }
    
    return Result::success($user);
}

// Chaining operations without nested try-catch
$result = parseEmail('user@example.com')
    ->flatMap('findUserByEmail')
    ->map(fn($user) => $user['name'])
    ->onFailure(fn($error) => error_log("User lookup failed: {$error}"));

$displayName = $result->orElse('Guest');
echo "Welcome, {$displayName}!";

Pattern 2: The Circuit Breaker Pattern

When a service repeatedly fails, the circuit breaker prevents cascading failures by temporarily blocking calls to the failing service, allowing it time to recover. This pattern is essential for distributed systems and external API integrations.

<?php
class CircuitBreaker
{
    private int $failureThreshold;
    private int $resetTimeoutSeconds;
    private int $failureCount = 0;
    private ?int $lastFailureTimestamp = null;
    private bool $isOpen = false;
    private string $serviceName;
    
    public function __construct(string $serviceName, int $failureThreshold = 5, int $resetTimeoutSeconds = 60)
    {
        $this->serviceName = $serviceName;
        $this->failureThreshold = $failureThreshold;
        $this->resetTimeoutSeconds = $resetTimeoutSeconds;
    }
    
    public function call(callable $operation): mixed
    {
        if ($this->isOpen) {
            if ($this->shouldAttemptReset()) {
                $this->isOpen = false; // Half-open state
                error_log("Circuit breaker [{$this->serviceName}]: Entering half-open state");
            } else {
                throw new CircuitBreakerOpenException(
                    "Circuit breaker is open for service: {$this->serviceName}. " .
                    "Please try again later."
                );
            }
        }
        
        try {
            $result = $operation();
            $this->recordSuccess();
            return $result;
        } catch (Exception $e) {
            $this->recordFailure();
            
            if ($this->failureCount >= $this->failureThreshold) {
                $this->trip();
            }
            
            throw $e;
        }
    }
    
    private function recordSuccess(): void
    {
        $this->failureCount = 0;
        $this->lastFailureTimestamp = null;
        error_log("Circuit breaker [{$this->serviceName}]: Success recorded");
    }
    
    private function recordFailure(): void
    {
        $this->failureCount++;
        $this->lastFailureTimestamp = time();
        error_log("Circuit breaker [{$this->serviceName}]: Failure #{$this->failureCount} recorded");
    }
    
    private function trip(): void
    {
        $this->isOpen = true;
        error_log("Circuit breaker [{$this->serviceName}]: TRIPPED - circuit is now open");
        
        // Trigger alerts
        sendAlert("Circuit breaker tripped for: {$this->serviceName}");
    }
    
    private function shouldAttemptReset(): bool
    {
        if ($this->lastFailureTimestamp === null) {
            return true;
        }
        
        $elapsed = time() - $this->lastFailureTimestamp;
        return $elapsed >= $this->resetTimeoutSeconds;
    }
    
    public function getState(): array
    {
        return [
            'service' => $this->serviceName,
            'is_open' => $this->isOpen,
            'failure_count' => $this->failureCount,
            'threshold' => $this->failureThreshold,
        ];
    }
}

// Usage with an external API
$paymentCircuitBreaker = new CircuitBreaker('PaymentGateway', 3, 120);

try {
    $result = $paymentCircuitBreaker->call(function() use ($paymentData) {
        return PaymentGateway::process($paymentData);
    });
    echo "Payment processed: " . $result->transactionId;
} catch (CircuitBreakerOpenException $e) {
    error_log($e->getMessage());
    // Queue the payment for later retry
    PaymentQueue::enqueue($paymentData);
    echo "Payment queued for processing. We'll notify you when complete.";
} catch (PaymentException $e) {
    error_log("Payment failed: " . $e->getMessage());
    echo "Payment failed. Please try a different method.";
}

Pattern 3: The Retry with Exponential Backoff Pattern

Transient failures (network timeouts, temporary service unavailability) often resolve themselves. The retry pattern with exponential backoff prevents overwhelming a recovering service while maximizing the chance of eventual success.

<?php
function withRetry(callable $operation, int $maxAttempts = 5, int $baseDelayMs = 100): mixed
{
    $attempt = 0;
    $lastException = null;
    
    while ($attempt < $maxAttempts) {
        try {
            $attempt++;
            return $operation();
        } catch (Exception $e) {
            $lastException = $e;
            
            // Determine if this exception is retryable
            if (!isRetryable($e)) {
                throw $e; // Non-transient failure, stop immediately
            }
            
            if ($attempt >= $maxAttempts) {
                break; // Exhausted all attempts
            }
            
            // Exponential backoff with jitter
            $delayMs = $baseDelayMs * pow(2, $attempt - 1);
            $jitterMs = random_int(0, $delayMs / 2);
            $totalDelayMicroseconds = ($delayMs + $jitterMs) * 1000;
            
            error_log(sprintf(
                "Retry attempt %d/%d after %.2f ms delay. Error: %s",
                $attempt,
                $maxAttempts,
                ($delayMs + $jitterMs),
                $e->getMessage()
            ));
            
            usleep($totalDelayMicroseconds);
        }
    }
    
    throw new MaxRetriesExceededException(
        "Operation failed after {$maxAttempts} attempts",
        0,
        $lastException
    );
}

function isRetryable(Exception $e): bool
{
    $retryableClasses = [
        'GuzzleHttp\Exception\ConnectException',
        'GuzzleHttp\Exception\ServerException',
        'PDOException',  // Could be transient connection loss
    ];
    
    foreach ($retryableClasses as $class) {
        if ($e instanceof $class) {
            return true;
        }
    }
    
    // Check for specific error codes
    if ($e instanceof PDOException) {
        $retryableCodes = ['HY000', '08004', '08007']; // Connection lost, timeout
        if (in_array($e->getCode(), $retryableCodes)) {
            return true;
        }
    }
    
    return false;
}

// Usage
try {
    $data = withRetry(function() {
        $response = HttpClient::get('https://api.example.com/orders');
        
        if ($response->getStatusCode() === 429) {
            throw new RateLimitException(
                (int) ($response->getHeader('Retry-After')[0] ?? 60)
            );
        }
        
        return json_decode($response->getBody(), true);
    }, 5, 200);
    
    echo "Fetched " . count($data) . " orders.";
    
} catch (MaxRetriesExceededException $e) {
    error_log("API call exhausted retries: " . $e->getMessage());
    echo "Service temporarily unavailable. Please try again in a few minutes.";
}

Pattern 4: Global Exception Handler with Middleware Pipeline

For web applications, wrapping the entire request lifecycle in a global exception handler ensures no error escapes to the client. Combining this with a middleware pipeline allows layered error processing (logging, sanitization, response formatting).

<?php
class ErrorHandlingMiddleware
{
    private array $handlers = [];
    
    public function addHandler(callable $handler): void
    {
        $this->handlers[] = $handler;
    }
    
    public function handleRequest(callable $requestHandler): void
    {
        try {
            $requestHandler();
        } catch (Throwable $e) {
            $context = [
                'exception' => get_class($e),
                'message' => $e->getMessage(),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'trace' => $e->getTraceAsString(),
                'timestamp' => date('c'),
                'request_id' => bin2hex(random_bytes(8)),
            ];
            
            // Run through middleware pipeline
            foreach ($this->handlers as $handler) {
                $handler($e, $context);
            }
            
            // Always render a safe response
            $this->renderSafeResponse($e, $context['request_id']);
        }
    }
    
    private function renderSafeResponse(Throwable $e, string $requestId): void
    {
        http_response_code($this->determineHttpStatus($e));
        
        // Never expose internal details in production
        if (getenv('APP_ENV') === 'production') {
            echo json_encode([
                'error' => 'An unexpected error occurred.',
                'request_id' => $requestId,
            ]);
        } else {
            echo json_encode([
                'error' => $e->getMessage(),
                'type' => get_class($e),
                'file' => $e->getFile(),
                'line' => $e->getLine(),
                'request_id' => $requestId,
            ]);
        }
    }
    
    private function determineHttpStatus(Throwable $e): int
    {
        if ($e instanceof ValidationException) return 422;
        if ($e instanceof NotFoundException) return 404;
        if ($e instanceof AuthenticationException) return 401;
        if ($e instanceof AuthorizationException) return 403;
        if ($e instanceof RateLimitException) return 429;
        if ($e instanceof ConflictException) return 409;
        
        return 500;
    }
}

// Configure the pipeline
$errorMiddleware = new ErrorHandlingMiddleware();

// Handler 1: Structured logging
$errorMiddleware->addHandler(function (Throwable $e, array $context) {
    error_log(json_encode($context));
});

// Handler 2: Send critical errors to monitoring service
$errorMiddleware->addHandler(function (Throwable $e, array $context) {
    if ($e->getCode() >= 500 || $e instanceof Error) {
        Sentry\captureException($e, ['extra' => $context]);
    }
});

// Handler 3: Notify developers for critical production errors
$errorMiddleware->addHandler(function (Throwable $e, array $context) {
    if (getenv('APP_ENV') === 'production' && !$e instanceof ValidationException) {
        $message = sprintf(
            "[%s] %s - Request ID: %s",
            get_class($e),
            $e->getMessage(),
            $context['request_id']
        );
        sendSlackNotification('#production-errors', $message);
    }
});

// Wrap your application entry point
$errorMiddleware->handleRequest(function() {
    // Your entire application logic here
    $router->dispatch($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);
});

Pattern 5: Transactional Error Handling with Compensation Actions

When a multi-step operation fails partway through, you need to undo previously completed steps. The Saga pattern uses compensation actions to maintain consistency without traditional ACID transactions across services.

<?php
class SagaCoordinator
{
    private array $executedSteps = [];
    private array $compensations = [];
    
    public function addStep(callable $action, callable $compensation, string $stepName): void
    {
        $this->compensations[] = [
            'action' => $action,
            'compensation' => $compensation,
            'name' => $stepName,
        ];
    }
    
    public function execute(): void
    {
        try {
            foreach ($this->compensations as $step) {
                error_log("Saga: Executing step [{$step['name']}]");
                $step['action']();
                $this->executedSteps[] = $step;
                error_log("Saga: Step [{$step['name']}] completed successfully");
            }
        } catch (Throwable $e) {
            error_log("Saga: Failure at step [{$step['name']}]: " . $e->getMessage());
            $this->compensate();
            throw new SagaExecutionException(
                "Saga failed at step [{$step['name']}]: " . $e->getMessage(),
                0,
                $e
            );
        }
    }
    
    private function compensate(): void
    {
        // Reverse order: undo the most recent step first
        $stepsToUndo = array_reverse($this->executedSteps);
        
        error_log("Saga: Starting compensation for " . count($stepsToUndo) . " steps");
        
        foreach ($stepsToUndo as $step) {
            try {
                error_log("Saga: Compensating step [{$step['name']}]");
                $step['compensation']();
                error_log("Saga: Compensation for [{$step['name']}] complete");
            } catch (Throwable $e) {
                // Log compensation failure but continue with remaining compensations
                error_log("CRITICAL: Compensation failed for step [{$step['name']}]: " . $e->getMessage());
                // This requires manual intervention
                sendAlert("Manual intervention needed: Compensation failed for [{$step['name']}]");
            }
        }
    }
}

// Usage: Order placement saga
$saga = new SagaCoordinator();

// Step 1: Reserve inventory
$saga->addStep(
    function() use ($productId, $quantity) {
        $result = InventoryService::reserve($productId, $quantity);
        if (!$result->success) {
            throw new InventoryException("Failed to reserve inventory");
        }
    },
    function() use ($productId, $quantity) {
        InventoryService::release($productId, $quantity);
    },
    'ReserveInventory'
);

// Step 2: Charge payment
$saga->addStep(
    function() use ($paymentMethod, $amount) {
        $result = PaymentService::charge($paymentMethod, $amount);
        if (!$result->success) {
            throw new PaymentException("Payment authorization failed", $result->transactionId);
        }
    },
    function() use ($paymentMethod, $amount) {
        PaymentService::refund($paymentMethod, $amount);
    },
    'ChargePayment'
);

// Step 3: Create shipment
$saga->addStep(
    function() use ($orderId, $address) {
        ShippingService::createLabel($orderId, $address);
    },
    function() use ($orderId) {
        ShippingService::voidLabel($orderId);
    },
    'CreateShipment'
);

try {
    $saga->execute();
    echo "Order placed successfully!";
} catch (SagaExecutionException $e) {
    error_log("Order saga failed: " . $e->getMessage());
    echo "We couldn't complete your order. Any charges will be refunded within 24 hours.";
}

Error Handling Best Practices

1. Never Expose Raw Errors to Users

Stack traces, file paths, and SQL queries in error messages are gold mines for attackers. Always sanitize output in production environments.

<?php
// BAD: Exposes internal details
try {
    $user = getUserById($_GET['id']);
} catch (Exception $e) {
    echo "Error: " . $e->getMessage(); // May leak SQL, file paths
    echo "<pre>" . $e->getTraceAsString() . "</pre>";
}

// GOOD: Logs details internally, shows safe message
try {
    $user = getUserById($_GET['id']);
} catch (Exception $e) {
    error_log("getUserById failed: " . $e->getMessage());
    error_log($e->getTraceAsString());
    echo "Unable to load user profile. Please try again.";
}

2. Catch Specific Exceptions, Not Generic Ones (Except at the Top Level)

Catching Exception or Throwable everywhere masks bugs and makes debugging impossible. Reserve broad catch blocks for the outermost request boundary.

<?php
// BAD: Swallows everything, including bugs
try {
    processOrder($data);
} catch (Exception $e) {
    // This catches TypeError, ParseError, LogicException — all of which indicate bugs
    echo "Something went wrong.";
}

// GOOD: Catch specific, recoverable exceptions
try {
    processOrder($data);
} catch (PaymentException $e) {
    echo "Payment could not be processed: " . $e->getMessage();
} catch (InventoryException $e) {
    echo "Some items are out of stock.";
}
// Let TypeError, LogicException bubble up to the global handler — they're bugs

3. Preserve Exception Chains

When catching and re-throwing, always pass the original exception as the third argument to maintain the full causal chain for debugging.

<?php
try {
    $api->syncOrders();
} catch (ApiConnectionException $e) {
    throw new SyncFailedException(
        "Order sync failed due to API connectivity issue",
        0,
        $e  // Preserves the original exception
    );
}

// Later, when logging:
// SyncFailedException: Order sync failed... 
//   Caused by: ApiConnectionException: Connection timeout to api.example.com

4. Log Errors with Rich Context

A bare error message is nearly useless during an incident investigation. Include request metadata, user identifiers, timestamps, and correlation IDs.

<?php
function logError(Throwable $e, array $extraContext = []): void
{
    $context = array_merge([
        'exception' => get_class($e),
        'message' => $e->getMessage(),
        'file' => $e->getFile(),
        'line' => $e->getLine(),
        'timestamp' => date('c'),
        'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8)),
        'user_id' => $_SESSION['user_id'] ?? null,
        'url' => $_SERVER['REQUEST_URI'] ?? 'cli',
        'method' => $_SERVER['REQUEST_METHOD'] ?? 'N/A',
        'ip' => $_SERVER['REMOTE_ADDR'] ?? 'N/A',
    ], $extraContext);
    
    error_log(json_encode($context, JSON_PRETTY_PRINT));
}

5. Use Assertions for Invariants During Development

Assertions verify that impossible conditions never occur. They're zero-cost in production (when assertions are disabled) but invaluable during development.

<

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles