0

Exception chaining — $previous parameter

Intermediate5 min read·php-10-011

Concept

Exception chaining allows you to wrap one exception in another while preserving the original as the "previous" exception. This creates a chain of causality — you can trace exactly what caused what, even across layer boundaries.

The $previous parameter: Every \Exception and \Error constructor accepts a \Throwable $previous = null parameter. Pass the original exception as previous when wrapping it in a higher-level exception.

Why chaining matters: In a layered architecture, low-level exceptions (like \PDOException) should not bubble up to the HTTP layer — they expose database details. But you still need the original exception for debugging. Wrapping: throw new DatabaseException("User save failed", previous: $pdoException) gives callers a meaningful high-level exception while preserving the original for logging.

$e->getPrevious(): Returns the previous exception (or null). getPrevious() returns a \Throwable, so you can chain multiple levels.

Stack traces and chains: When you log an exception chain, log all levels. Monolog and Laravel's built-in logging will trace through getPrevious() to include the full chain. Sentry shows exception chains as a sequence.

Code Example

php
<?php
declare(strict_types=1);

// Exception chaining — wrap low-level exception in high-level
class UserRepository
{
    public function save(array $user): void
    {
        try {
            $this->db->execute("INSERT INTO users ...", $user);
        } catch (\PDOException $e) {
            // Don't leak PDO details to higher layers
            throw new \RuntimeException(
                "Failed to save user: {$user['email']}",
                previous: $e  // original PDO exception preserved
            );
        }
    }
}

// Caller can catch high-level but still access the original
try {
    $repo->save(['email' => 'alice@example.com']);
} catch (\RuntimeException $e) {
    echo "High-level: " . $e->getMessage() . "\n";

    $original = $e->getPrevious();
    if ($original instanceof \PDOException) {
        echo "DB cause: " . $original->getMessage() . "\n";
        echo "SQL state: " . $original->getCode() . "\n";
    }
}

// Full chain logging
function logExceptionChain(\Throwable $e): void
{
    $depth = 0;
    $current = $e;
    while ($current !== null) {
        error_log(sprintf(
            "[%d] %s: %s in %s:%d",
            $depth++,
            $current::class,
            $current->getMessage(),
            $current->getFile(),
            $current->getLine()
        ));
        $current = $current->getPrevious();
    }
}

// Collecting all exceptions in a chain
function getExceptionChain(\Throwable $e): array
{
    $chain = [];
    $current = $e;
    while ($current !== null) {
        $chain[] = $current;
        $current = $current->getPrevious();
    }
    return $chain;
}

// Deep chain example
try {
    try {
        throw new \PDOException("Connection refused");
    } catch (\PDOException $db) {
        throw new \RuntimeException("DB operation failed", previous: $db);
    }
} catch (\RuntimeException $e) {
    throw new \LogicException("Service unavailable", previous: $e);
}

// When catching, e->getPrevious()->getPrevious() would give the PDOException