Kodokon kodokon.com

Exceptions: hierarchy, finally, global handler

Build a complete error strategy: chained business exceptions, controlled finally and global handlers as a last line of defense.

9 min · 3 questions

Open this lesson in Kodokon

Since PHP 7, everything that gets thrown implements Throwable, with two branches: Error (engine and typing failures, like TypeError) and Exception (application failures). You only catch Error at the boundaries of the program. On the application side, the SPL provides a ready-made hierarchy: LogicException signals a developer bug, to be fixed; RuntimeException a runtime contingency, to be handled. The professional reflex: derive your business exceptions from these bases, one per identifiable situation.

PHP
<?php

declare(strict_types=1);

abstract class AppException extends RuntimeException
{
}

final class UserNotFound extends AppException
{
    public static function withEmail(
        string $email
    ): self {
        return new self("No user for $email");
    }
}

try {
    throw UserNotFound::withEmail('lea@example.com');
} catch (UserNotFound $e) {
    echo $e->getMessage();
}
Business hierarchy: you catch UserNotFound, AppException or RuntimeException depending on the level.

try/catch is only worth it if you know what to do with the error - otherwise, let it bubble up. Three tools refine the control: the multi-catch catch (JsonException | ValueError $e) groups identical handling; the previous parameter chains the original cause when you translate a technical exception into a business one; finally runs in every case - success, exception, even an early return - ideal for releasing a resource.

PHP
<?php

declare(strict_types=1);

final class PayloadInvalid extends RuntimeException
{
}

function decode(string $json): mixed
{
    try {
        return json_decode(
            $json,
            true,
            flags: JSON_THROW_ON_ERROR,
        );
    } catch (JsonException $e) {
        throw new PayloadInvalid(
            'Invalid JSON payload',
            previous: $e,
        );
    } finally {
        echo "decode() finished\n";
    }
}

try {
    decode('{broken');
} catch (PayloadInvalid $e) {
    echo $e->getPrevious()?->getMessage();
}
Exception translation: the technical cause stays accessible via getPrevious().

At the end of the chain, two global handlers close the setup. set_exception_handler catches everything that wasn't handled: structured logging, a generic 500 response - without ever disclosing the message or trace to the user. set_error_handler converts legacy errors (warnings, notices) into ErrorException, unifying the two worlds: everything becomes an exception, everything follows the same handling path.

PHP
<?php

declare(strict_types=1);

set_error_handler(function (
    int $severity,
    string $message,
    string $file,
    int $line,
): bool {
    throw new ErrorException(
        $message,
        0,
        $severity,
        $file,
        $line,
    );
});

set_exception_handler(function (Throwable $e): void {
    error_log($e->getMessage());
    http_response_code(500);
    echo 'A technical error occurred.';
});

trigger_error('Legacy warning', E_USER_WARNING);
echo 'Never reached';
The warning becomes an exception, uncaught: the global handler responds, then the script stops.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What do Error and Exception have in common in modern PHP?
    • Error inherits from Exception
    • Both implement the Throwable interface
    • Both are reserved for the PHP engine
  2. When does the finally block run?
    • Only if no exception was thrown
    • Only after a catch
    • In every case: success, exception caught or not, early return
  3. What is the previous parameter of an exception for?
    • Preserving the original exception when you translate it into a higher-level exception
    • Preventing the exception from bubbling up the call stack
    • Defining the exception to throw on the next failure