Error Handling in PHP: Exceptions, Error Types, and Recovery Patterns

Bryan Heath Bryan Heath
· · 2 min read

Error handling is one of those things every PHP developer does, but few do well. The difference between code that crashes mysteriously in production and code that fails gracefully comes down to understanding PHP's error model — the hierarchy of throwables, when to throw versus when to return, and how to design catch blocks that actually solve problems instead of hiding them.

Let's walk through the full picture: how PHP's error types are organized, how to build exception hierarchies that make catching precise, and patterns for recovery that don't swallow the failures you need to know about.

The Throwable Hierarchy

Since PHP 7, everything that can be thrown implements the Throwable interface. It splits into two branches: Error for engine-level problems and Exception for application-level problems.

Throwable
├── Error (engine-level)
│   ├── TypeError
│   ├── ValueError
│   ├── DivisionByZeroError
│   ├── ArithmeticError
│   └── ...
└── Exception (application-level)
    ├── RuntimeException
    ├── InvalidArgumentException
    ├── LogicException
    │   ├── DomainException
    │   ├── LengthException
    │   └── OutOfRangeException
    └── ...

Error subclasses represent things the engine catches: passing a string where an int is expected (TypeError), dividing by zero (DivisionByZeroError), or using an invalid enum value (ValueError). You generally don't throw these yourself.

Exception subclasses are what you use in your application code. RuntimeException signals something that can only be detected at runtime (a file that doesn't exist, a network timeout). LogicException signals a bug in the code itself (calling a method with invalid arguments, violating a precondition).

The key rule: catch Throwable at the outermost boundary of your application (a global handler, middleware, or top-level try/catch). Everywhere else, catch specific exception types so you only handle what you actually know how to recover from.

Creating Custom Exception Hierarchies

SPL exceptions are a starting point, but real applications need domain-specific exceptions. The pattern is straightforward: create a base exception for your package or domain, then extend it for specific failure modes.

<?php

namespace App\Billing\Exceptions;

use RuntimeException;

class PaymentException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly string $transactionId = '',
        int $code = 0,
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, $code, $previous);
    }
}

class PaymentDeclinedException extends PaymentException {}

class PaymentGatewayException extends PaymentException {}

class InsufficientFundsException extends PaymentDeclinedException {}

This hierarchy gives you catch granularity. You can catch InsufficientFundsException to show a specific message, catch PaymentDeclinedException to handle all decline reasons generically, or catch PaymentException to handle any billing failure. Adding the transactionId property to the base exception means every catch block has context for logging without needing to parse the message string.

When to Throw vs When to Return

Exceptions are for unexpected failures — situations where the calling code probably can't continue normally. Violated preconditions, infrastructure failures, and corrupted state all warrant exceptions.

Return values (or result objects) are better for expected outcomes. Validation failures, search queries with no results, and user input that doesn't match a pattern are all normal — the system anticipated them.

<?php

// Good: exception for unexpected failure
function loadConfig(string $path): array
{
    if (! file_exists($path)) {
        throw new \RuntimeException("Config file not found: {$path}");
    }

    $data = json_decode(file_get_contents($path), true);

    if (json_last_error() !== JSON_ERROR_NONE) {
        throw new \RuntimeException('Invalid JSON in config: ' . json_last_error_msg());
    }

    return $data;
}

// Good: return value for expected outcome
function findUserByEmail(string $email): ?User
{
    return User::query()->where('email', $email)->first();
}

// Also good: result object when you need failure details
class ValidationResult
{
    public function __construct(
        public readonly bool $valid,
        public readonly array $errors = [],
    ) {}
}

function validateOrder(array $data): ValidationResult
{
    $errors = [];

    if (empty($data['items'])) {
        $errors[] = 'Order must contain at least one item.';
    }

    return new ValidationResult(empty($errors), $errors);
}

The litmus test: if the caller would always need to wrap your call in a try/catch just to handle a common case, you're using exceptions for control flow. Return a result instead.

The Art of Catching Exceptions

Most exception handling bugs come from catching too broadly or doing the wrong thing in the catch block. Here are the patterns to avoid and their fixes.

Anti-pattern: the silent swallow.

<?php

// Bad: swallows everything, hides real bugs
try {
    $order->process();
} catch (\Exception $e) {
    // "it's fine"
}

Anti-pattern: log and ignore.

<?php

// Bad: logs the message but continues as if nothing happened
try {
    $gateway->charge($amount);
} catch (\Exception $e) {
    Log::error($e->getMessage());
}
// Code continues here with no payment actually processed

The correct patterns: catch specific exceptions, add context when re-throwing, and let anything unexpected bubble up.

<?php

// Good: catch specific, re-throw with context
try {
    $response = $gateway->charge($amount);
} catch (PaymentDeclinedException $e) {
    // We know how to handle this — show the user a message
    return back()->withError('Your card was declined. Please try another.');
} catch (PaymentGatewayException $e) {
    // Wrap with context and let it bubble
    throw new \RuntimeException(
        "Payment gateway error for order {$order->id}",
        previous: $e,
    );
}
// Any other exception? We don't catch it — it bubbles to the global handler

Notice the previous parameter when re-throwing. This chains the original exception so your logs preserve the full stack trace. Never throw a new exception without linking the original — you'll lose the root cause.

Global Exception Handlers

Frameworks like Laravel handle this for you, but in plain PHP applications you need to set up your own safety net. PHP provides two functions: set_exception_handler for uncaught exceptions and set_error_handler for legacy PHP errors.

<?php

// Convert legacy PHP errors (warnings, notices) into exceptions
set_error_handler(function (int $severity, string $message, string $file, int $line): never {
    throw new \ErrorException($message, 0, $severity, $file, $line);
});

// Catch any exception that wasn't handled anywhere else
set_exception_handler(function (\Throwable $e): void {
    // Log the full exception with stack trace
    error_log(sprintf(
        "[%s] %s in %s:%d\nStack trace:\n%s",
        get_class($e),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString(),
    ));

    // Show a generic error page to the user
    http_response_code(500);

    if (getenv('APP_DEBUG') === 'true') {
        echo "<pre>{$e}</pre>";
    } else {
        echo 'Something went wrong. Please try again later.';
    }
});

The set_error_handler call is particularly important. By converting legacy errors to ErrorException instances, you unify PHP's two error systems into one. A warning about a missing file becomes a catchable exception instead of a line in the log that nobody reads. This is exactly what frameworks like Laravel do internally.

Recovery Patterns

Not every exception should crash the request. For transient failures — network timeouts, rate limits, temporary service outages — you can build structured recovery into your code.

Retry with exponential backoff is the most common pattern for transient failures:

<?php

function retry(callable $operation, int $maxAttempts = 3, int $baseDelayMs = 100): mixed
{
    $lastException = null;

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            return $operation();
        } catch (\RuntimeException $e) {
            $lastException = $e;

            if ($attempt < $maxAttempts) {
                $delayMs = $baseDelayMs * (2 ** ($attempt - 1)); // 100, 200, 400...
                usleep($delayMs * 1000);
            }
        }
    }

    throw new \RuntimeException(
        "Operation failed after {$maxAttempts} attempts",
        previous: $lastException,
    );
}

// Usage
$response = retry(fn () => $httpClient->post('/api/charge', $payload));

Fallback values work for non-critical operations where a degraded response is better than no response:

<?php

function getExchangeRate(string $currency): float
{
    try {
        return $exchangeApi->getRate($currency);
    } catch (PaymentGatewayException $e) {
        Log::warning("Exchange rate API failed, using cached rate", [
            'currency' => $currency,
            'error' => $e->getMessage(),
        ]);

        return Cache::get("exchange_rate_{$currency}", 1.0);
    }
}

The circuit breaker pattern takes this further. Track failures to an external service, and after a threshold is crossed, stop calling it entirely for a cooldown period. This prevents a failing dependency from dragging down your entire application with slow timeouts. Libraries like ackintosh/ganesha implement this for PHP.

Logging Without Swallowing

The most common error handling mistake I see in production code is treating logging as handling. This pattern looks responsible but is dangerous:

<?php

// Dangerous: logs the exception but continues as if it was handled
try {
    $invoice->finalize();
} catch (\Exception $e) {
    Log::error($e->getMessage());
}

// Execution continues — but the invoice was never finalized.
// Downstream code assumes it was. Bugs ensue.

Logging an exception is not the same as handling it. Handling means you've taken a corrective action: returned a fallback, retried the operation, or notified the user. If all you're doing is logging, you still need to re-throw or otherwise stop the current operation.

Here are the correct patterns:

<?php

// Pattern 1: Log and re-throw
try {
    $invoice->finalize();
} catch (BillingException $e) {
    Log::error('Invoice finalization failed', [
        'invoice_id' => $invoice->id,
        'exception' => $e,
    ]);

    throw $e;
}

// Pattern 2: Log and throw a wrapped exception with added context
try {
    $invoice->finalize();
} catch (BillingException $e) {
    throw new OrderProcessingException(
        "Failed to finalize invoice {$invoice->id} for order {$order->id}",
        previous: $e,
    );
}

// Pattern 3: Log and return an explicit failure (only when the caller expects it)
function tryFinalizeInvoice(Invoice $invoice): bool
{
    try {
        $invoice->finalize();
        return true;
    } catch (BillingException $e) {
        Log::warning('Invoice finalization failed, will retry later', [
            'invoice_id' => $invoice->id,
            'exception' => $e,
        ]);

        return false;
    }
}

Pattern 3 is only appropriate when the calling code explicitly checks the return value and takes action. If it ignores the return value, you're back to swallowing. When in doubt, re-throw.

Good error handling isn't about preventing errors — it's about making them visible, actionable, and recoverable. Build your exception hierarchies intentionally so catch blocks can be precise. Catch only what you can genuinely handle, and let everything else surface to your global handler. When you log, make sure you're also stopping or correcting the operation — not just recording that something went wrong and carrying on. The goal is code that fails loudly in development and gracefully in production, with enough context in every exception to diagnose the problem without reproducing it.

Share:

Related Posts