Skip to content

02 · Error Handling Advanced

Level 1 covered try/catch/finally, throwing built-in exceptions, and writing one custom exception class. In a real application you need more: a whole hierarchy of related exceptions, a way to preserve the original failure when you wrap it in a higher-level one, and a global safety net for whatever slips through every catch block.

Building an exception hierarchy

Instead of one flat custom exception, model your application's failure modes as a small class tree rooted in a base type. Callers can then catch broadly (the base type) or narrowly (a specific subtype) depending on how much detail they need.

<?php

// A base type for every error this application raises on purpose
abstract class AppException extends RuntimeException {}

class ValidationException extends AppException
{
    public function __construct(private array $errors)
    {
        parent::__construct("Validation failed: " . implode(", ", $errors));
    }

    public function getErrors(): array
    {
        return $this->errors;
    }
}

class NotFoundException extends AppException
{
    public function __construct(string $entity, int $id)
    {
        parent::__construct("$entity #$id not found");
    }
}

function findUser(int $id): array
{
    $users = [1 => ["id" => 1, "name" => "Ada"]];

    if (!isset($users[$id])) {
        throw new NotFoundException("User", $id);
    }
    return $users[$id];
}

try {
    findUser(99);
} catch (ValidationException $e) {
    echo "Bad input: " . implode(", ", $e->getErrors());
} catch (NotFoundException $e) {
    echo "Missing: " . $e->getMessage();
} catch (AppException $e) {
    // Safety net for any AppException subtype added later that isn't
    // handled above yet -- still narrower than catching every Throwable.
    echo "App error: " . $e->getMessage();
}
// Missing: User #99 not found

Marking AppException abstract stops anyone from throwing a bare, meaningless new AppException() — every thrown exception must be one of the specific subtypes, which keeps catch blocks meaningful.

Exception chaining: preserving the original cause

When you catch a low-level exception and rethrow a more meaningful one, don't discard the original — every built-in exception constructor accepts an optional third $previous argument for exactly this.

<?php

class DatabaseException extends RuntimeException {}
class ReportGenerationException extends RuntimeException {}

function queryDatabase(): never
{
    throw new DatabaseException("Connection refused on port 5432");
}

function generateReport(): void
{
    try {
        queryDatabase();
    } catch (DatabaseException $e) {
        // Wrap the low-level error in a message that makes sense at this
        // layer, but chain $e so nothing is lost.
        throw new ReportGenerationException(
            "Could not generate report",
            previous: $e,
        );
    }
}

try {
    generateReport();
} catch (ReportGenerationException $e) {
    echo $e->getMessage() . "\n";
    // Could not generate report
    echo "Caused by: " . $e->getPrevious()->getMessage() . "\n";
    // Caused by: Connection refused on port 5432
}

Without chaining, logs only ever show the high-level, user-facing message ("Could not generate report") and the real root cause is gone forever. getPrevious() walks back one link at a time; PHP's default uncaught-error output also prints the full chain automatically under "Next" sections.

finally and return: a subtle gotcha

finally always runs, even after a return inside try or catch — but a return inside finally silently overrides any value already being returned. Avoid returning from finally; use it only for cleanup.

<?php

function riskyRead(): string
{
    try {
        return "data";
    } finally {
        echo "cleanup ran\n";   // runs before the caller sees the return value
        // return "overridden";   // if uncommented, THIS becomes the return
        // value instead of "data" -- almost always a bug, not intentional
    }
}

echo riskyRead();
// cleanup ran
// data

Converting PHP errors into exceptions

Not every failure in PHP is an exception by default — some legacy-style functions raise warnings/notices instead. set_error_handler() lets you convert those into ErrorExceptions so they flow through the same try/catch machinery as everything else, instead of being easy to miss in a log file.

<?php

set_error_handler(function (int $severity, string $message, string $file, int $line) {
    // Converting to an exception means a stray warning can no longer be
    // silently ignored -- it now has to be caught (or it crashes loudly).
    throw new ErrorException($message, 0, $severity, $file, $line);
});

try {
    $value = $undefinedVariable;   // normally just a silent-ish warning
} catch (ErrorException $e) {
    echo "Caught as exception: " . $e->getMessage();
    // Caught as exception: Undefined variable $undefinedVariable
}

restore_error_handler();   // put PHP's default handler back

A last-resort handler for uncaught exceptions

set_exception_handler() registers a function that runs if an exception escapes every try/catch in your code. It cannot recover the program — execution ends right after — but it's the right place to log the failure and show a clean message instead of a raw stack trace.

<?php

set_exception_handler(function (Throwable $e): void {
    error_log("Uncaught: " . $e->getMessage());
    echo "Something went wrong. Please try again later.\n";
});

function process(): void
{
    throw new RuntimeException("Unexpected failure deep in the call stack");
}

process();   // nothing catches this -- the handler above takes over
// Something went wrong. Please try again later.

In a real app this handler typically writes to a proper log (or an error tracking service) rather than error_log(), and it never reveals internal details like stack traces or database errors to the end user.

Advanced error-handling cheat sheet

Tool Purpose
abstract class AppException extends RuntimeException Root of a custom exception hierarchy
new Foo($msg, previous: $e) Chain the original exception so context isn't lost
$e->getPrevious() Retrieve the chained (wrapped) exception, or null
set_error_handler() Convert PHP warnings/notices into catchable ErrorExceptions
set_exception_handler() Last-resort handler for anything left uncaught
restore_error_handler() Undo a custom set_error_handler() registration
finally Runs on every path out of try/catch — cleanup only, don't return here

How It Actually Works

Exception chaining (throw new AppException($msg, 0, $previous)) stores the earlier exception as a real object reference inside the new exception's previous property — the engine does nothing special here beyond ordinary property assignment, but PHP's uncaught-exception handler and getTraceAsString() walk that previous chain recursively, which is why chained exceptions print as "Caused by:" blocks. The finally/return gotcha exists because of when the return value is materialized: the try block's ZEND_RETURN opcode computes and stashes its return value before control transfers to the finally block's opcodes, and if finally executes its own ZEND_RETURN, that opcode overwrites the stashed value outright — the original return is silently discarded, not merged. Converting PHP errors (warnings, notices — which are engine-level diagnostics, not Throwables) into exceptions works via set_error_handler(), which registers a callback the engine calls instead of its default diagnostic-printing behavior whenever an E_WARNING-class opcode fault occurs; throwing an ErrorException from inside that callback is what makes the fault participate in the normal catch/finally unwinding machinery described above, since a plain PHP error never enters the try/catch table lookup on its own.

Exercise

Design a small exception hierarchy for a file-processing task: an abstract FileProcessingException extends RuntimeException, and two subclasses, FileNotReadableException and InvalidFormatException. Write a function loadConfig(string $path): array that throws FileNotReadableException if file_exists($path) is false, and InvalidFormatException (chaining the original JsonException as $previous) if json_decode with JSON_THROW_ON_ERROR fails. Call it in a loop over a few paths (some missing, some containing invalid JSON) and handle each subtype with its own catch block, printing the chained previous message when there is one.