Skip to content
System nominalUTC --:--:--Articles 041
MODULE 04 Lesson 11 / 15 30 MIN

Errors and resources

Exceptions, Custom Hierarchies and finally

Throwable is the root, Error and Exception are siblings, and finally runs on both paths. Build hierarchies a caller can act on, and keep the previous cause.

A function throws InvalidArgumentException. The caller wraps the whole request in catch (Exception $e), logs the message, and returns a 500. Three months later the same codebase adds a retry loop around the payment gateway, and every declined card is retried four times. Both failures arrived as the same type, so nothing in the catch block could tell them apart, and nobody had a reason to look.

The type is the information. A single exception class thrown from fifteen places is a single branch in every caller, which means the only handling available is "something went wrong". A hierarchy where each failure has its own class lets the caller decide which ones to retry, which to report to the user, and which to let kill the request. Getting that wrong is not a style problem; it is the difference between a retry loop that helps and one that charges a customer four times.

Before the design question, there is a model question, because PHP's exception tree is not shaped the way most languages' trees are. Examples run on PHP 8.5.6, and the paths are shortened to /path/to/.

Throwable is the root, and Error is not an Exception

Since PHP 7 the engine has two exception branches with a common interface. Throwable is what a catch block can name to intercept anything. Under it sit Exception and Error, and they are siblings: an Error is not a kind of Exception, so catch (Exception $e) does not see it. Printing which classes implement what makes the shape concrete:

<?php
declare(strict_types=1);

$types = [Error::class, Exception::class, TypeError::class, ValueError::class, ErrorException::class];

foreach ($types as $type) {
    printf("%-14s Throwable=%s  Error=%s  Exception=%s\n",
        $type,
        var_export(is_subclass_of($type, Throwable::class) || $type === Throwable::class, true),
        var_export(is_subclass_of($type, Error::class) || $type === Error::class, true),
        var_export(is_subclass_of($type, Exception::class) || $type === Exception::class, true),
    );
}

foreach ([Error::class, TypeError::class, Exception::class, RuntimeException::class] as $type) {
    printf("%-14s parent=%s\n", $type, get_parent_class($type) ?: '(none)');
}
Error          Throwable=true  Error=true  Exception=false
Exception      Throwable=true  Error=false  Exception=true
TypeError      Throwable=true  Error=true  Exception=false
ValueError     Throwable=true  Error=true  Exception=false
ErrorException Throwable=true  Error=false  Exception=true

Error              parent=(none)
TypeError          parent=Error
Exception          parent=(none)
RuntimeException   parent=Exception

This bites when someone writes the catch block from memory. A type error is an Error, so a handler for Exception never runs:

<?php
declare(strict_types=1);

function parseAge(string $raw): int
{
    return (int) $raw;
}

try {
    parseAge(['not a string']);
} catch (Exception $e) {
    echo "caught as Exception: ", $e->getMessage(), PHP_EOL;
} catch (Error $e) {
    printf("caught as %s: %s\n", $e::class, $e->getMessage());
}
caught as TypeError: parseAge(): Argument #1 ($raw) must be of type string, array given, called in /path/to/errorcatch.php on line 10

The Exception branch was skipped and the Error branch took the call. The message names the function, the parameter, the expected type and the given type, and then names the call site. That is enough to fix the bug without a debugger, which is why the engine's own errors are worth reading rather than hiding.

The rule that follows: TypeError, ValueError, ArgumentCountError, DivisionByZeroError and ArithmeticError are all Error. They describe the program being wrong, and a catch block for them is usually a bug in the catch block. Exceptions describe conditions a running program can meet and respond to: a missing file, a timeout, a rejected card.

An uncaught exception is a fatal with a stack trace

When nothing catches a Throwable, the engine treats it exactly like a fatal error, prints the trace and exits non-zero. This is the report you see in a log after an incident, so it is worth being able to read it deliberately rather than skimming it.

<?php
declare(strict_types=1);

final class InsufficientFunds extends RuntimeException
{
}

function withdraw(string $account, int $cents): void
{
    throw new InsufficientFunds("Account {$account} cannot cover {$cents} cents");
}

function handleTransfer(string $from, int $cents): void
{
    withdraw($from, $cents);
}

handleTransfer('ana', 9000);
Fatal error: Uncaught InsufficientFunds: Account ana cannot cover 9000 cents in /path/to/uncaught.php:10
Stack trace:
#0 /path/to/uncaught.php(15): withdraw('ana', 9000)
#1 /path/to/uncaught.php(18): handleTransfer('ana', 9000)
#2 {main}
  thrown in /path/to/uncaught.php on line 10

Four facts are in that block. The exception class and message are InsufficientFunds and the sentence your code composed. The throw site is line 10, and the phrase thrown in repeats it at the end. Frames #0 and #1 are the calls that led there, innermost first, with the arguments rendered. {main} is top-level code, the point where the call chain starts.

Read it bottom-up for the sequence and top-down for the cause. #2 {main} called handleTransfer('ana', 9000), which called withdraw('ana', 9000) on line 15, which threw on line 10. The arguments in the trace are the actual values, which is useful during debugging and a reason to be careful about what reaches a shared log in production.

Catch the exception you can actually handle

Ordering a catch list is not decoration: PHP checks the branches in the order written and the first match wins. Put the broad type first and the specific ones become dead code. The useful pattern is narrow to broad, with the narrow branches doing different work.

<?php
declare(strict_types=1);

final class ApiPaymentFailed extends RuntimeException
{
}

function charge(string $currency, int $cents): string
{
    if ($currency !== 'EUR') {
        throw new InvalidArgumentException("Unsupported currency {$currency}");
    }

    throw new ApiPaymentFailed('gateway returned 402');
}

foreach ([['USD', 1000], ['EUR', 2500]] as [$currency, $cents]) {
    try {
        printf("charged: %s\n", charge($currency, $cents));
    } catch (InvalidArgumentException $e) {
        printf("bad request: %s\n", $e->getMessage());
    } catch (ApiPaymentFailed $e) {
        printf("upstream failure, retry later: %s\n", $e->getMessage());
    }
}
bad request: Unsupported currency USD
upstream failure, retry later: gateway returned 402

Both branches ran, and each printed something different because the branches mean different things. The first is a caller error: retrying will produce the same result, so the right response is to tell the caller. The second is an upstream failure: retrying may work, so the right response is a delay and a second attempt. Neither distinction is available if the function throws RuntimeException for both cases, which is what a library without a hierarchy forces on you.

The other half of the rule concerns the catch-all. catch (Throwable $e) at the top of a request handler is correct, because something has to produce a response. The same clause inside business logic is where bugs go to die: it catches the TypeError that means you passed the wrong argument, logs it as an application error, and returns a plausible-looking result. The rule to hold onto is that you catch what you can handle, and you handle what you catch. If the only thing you can do with an exception is log it, and the caller above you is in a better position to log it, let it travel.

A broader catch is not a safer one

Every level you widen the catch by is a class of failure you have agreed to treat as handled. Widening from ApiPaymentFailed to RuntimeException silently takes responsibility for timeouts, PDO errors and the library's own programming mistakes.

A domain hierarchy carries the data the caller needs

An exception object can hold whatever the caller needs to decide. The message is for humans; the typed properties are for code. Below, one base class marks every failure from the ledger, and two subclasses add the facts a caller would otherwise have to parse out of a string.

<?php
declare(strict_types=1);

class LedgerException extends RuntimeException
{
}

final class InsufficientFunds extends LedgerException
{
    public function __construct(
        public readonly string $account,
        public readonly int $requestedCents,
        public readonly int $availableCents,
    ) {
        parent::__construct(sprintf(
            'Account %s needs %d cents but has %d',
            $account,
            $requestedCents,
            $availableCents,
        ));
    }
}

final class AccountNotFound extends LedgerException
{
    public function __construct(public readonly string $account)
    {
        parent::__construct("No such account: {$account}");
    }
}

The base class is not final because it exists to be extended, and the subclasses are final because they exist to be caught by name. If a subclass is extended further, the catch branch for the subclass starts intercepting failures that were never meant to be handled that way. Readonly promoted properties keep the exception immutable, which matters because an exception can be inspected by several handlers on its way up the stack.

Driving it from a loop makes the value obvious:

<?php
declare(strict_types=1);

function withdraw(string $account, int $cents): void
{
    $balances = ['ana' => 2500];

    if (!array_key_exists($account, $balances)) {
        throw new AccountNotFound($account);
    }

    if ($balances[$account] < $cents) {
        throw new InsufficientFunds($account, $cents, $balances[$account]);
    }
}

foreach ([['ana', 1000], ['bo', 100], ['ana', 9000]] as [$account, $cents]) {
    try {
        withdraw($account, $cents);
        printf("withdrew %d cents from %s\n", $cents, $account);
    } catch (InsufficientFunds $e) {
        printf(
            "declined: %s is short by %d cents\n",
            $e->account,
            $e->requestedCents - $e->availableCents,
        );
    } catch (AccountNotFound $e) {
        printf("declined: %s\n", $e->getMessage());
    } catch (LedgerException $e) {
        printf("other ledger failure: %s\n", $e->getMessage());
    }
}
withdrew 1000 cents from ana
declined: No such account: bo
declined: ana is short by 6500 cents

The shortfall comes from subtracting two properties. No substring, no regular expression, no assumption about the message's wording. That is the test for whether a custom exception earns its place: if the caller would otherwise parse getMessage(), the exception is missing a property. If the caller only ever prints the message, a plain RuntimeException with a good sentence is enough, and adding classes buys nothing.

The base class earns its keep in the third branch. A caller that does not care which ledger failure occurred can catch LedgerException once, and a caller that does care can catch the subclass. Both are possible only because the subclasses share a parent that means something.

finally runs on both exits, and return inside it lies

finally executes whether the block completed, returned or threw. The practical use is a resource that must be released, and the reason to keep the block short is that a return inside it overrides everything else.

<?php
declare(strict_types=1);

function releaseLock(callable $work): string
{
    $attempts = 0;

    try {
        return $work();
    } finally {
        $attempts++;
        echo "  finally: unlocks after {$attempts} attempt(s)\n";
    }
}

echo "1. normal return\n";
echo "  result: ", releaseLock(static fn (): string => 'ok'), PHP_EOL;

echo "\n2. exception on the way out\n";
try {
    releaseLock(static function (): string {
        throw new RuntimeException('locked by another worker');
    });
} catch (RuntimeException $e) {
    echo "  caught: ", $e->getMessage(), PHP_EOL;
}

function sneaky(): string
{
    try {
        throw new RuntimeException('this will be swallowed');
    } finally {
        return 'clean exit';
    }
}

function changed(): int
{
    $total = 10;
    try {
        return $total;
    } finally {
        $total = 99;
    }
}

echo "\n3. return inside finally discards the exception\n";
echo "  result: ", sneaky(), PHP_EOL;
echo "\n4. changing the return value in finally does not work\n";
echo "  result: ", changed(), PHP_EOL;
1. normal return
  finally: unlocks after 1 attempt(s)
  result: ok

2. exception on the way out
  finally: unlocks after 1 attempt(s)
  caught: locked by another worker

3. return inside finally discards the exception
  result: clean exit

4. changing the return value in finally does not work
  result: 10

Case 1 prints the cleanup line before the result, because the value from return $work() is computed first, the finally block runs, and only then does the caller's echo happen. Case 2 shows that finally runs while the exception is still in flight: the unlock happens, and the exception continues to the catch in the caller. That is the entire point of the construct. Cleanup that must happen on both paths belongs there, and a lock released in the wrong place is a deadlock waiting for a Friday deploy.

Case 3 is the trap. The return in finally replaced the exception with a normal return value, so RuntimeException('this will be swallowed') disappeared without a trace and the caller saw a successful call. Case 4 shows a related surprise: assigning to $total inside finally does not change the returned value, because the return value was copied before the block ran. Writing return inside finally is legal and almost always a mistake outside of a deliberate override.

Chaining keeps the original cause attached

When you translate a low-level failure into a domain one, the original exception should not be discarded. The previous argument to any exception constructor stores it, and getPrevious() walks the chain.

<?php
declare(strict_types=1);

final class ReportUnavailable extends RuntimeException
{
}

function loadRows(string $path): array
{
    try {
        $raw = @file_get_contents($path);
        if ($raw === false) {
            throw new RuntimeException('file_get_contents returned false');
        }

        return json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
    } catch (Throwable $cause) {
        throw new ReportUnavailable("Cannot load report from {$path}", previous: $cause);
    }
}

try {
    loadRows('/path/to/report.json');
} catch (ReportUnavailable $e) {
    echo "outer: ", $e->getMessage(), PHP_EOL;

    for ($cause = $e->getPrevious(); $cause !== null; $cause = $cause->getPrevious()) {
        printf("  caused by %s: %s\n", $cause::class, $cause->getMessage());
    }
}
outer: Cannot load report from /path/to/report.json
  caused by RuntimeException: file_get_contents returned false

The caller catches one type, ReportUnavailable, and reads its own sentence. Anyone debugging gets the chain. The for loop is written as a walk because getPrevious() can be several links deep: a timeout wrapped in a client error wrapped in a domain error is normal, and a single getPrevious() call would show you only the middle one.

The previous argument is named, which is the clearest way to pass it: every built-in exception constructor accepts it as the third parameter, and named arguments let you skip the code and message-hint parameters you do not want to set. Whatever you do, keep the original. An exception that says "cannot load report" with no chain tells the next person the what and not the why.

Two limits on all of this. Exceptions are for conditions your program can survive, not for control flow; a loop that throws and catches an exception per iteration is slower and harder to follow than a loop with an if. And a hierarchy does not document itself. A subclass named InsufficientFunds tells a reader what happened, but nothing tells a reader whether the caller should retry; that belongs in a comment on the class or in the method's contract, and neither is enforced by the compiler.

Lab 25 minutes

You are writing the import path for a pricing feed. It has to fetch a URL, and it fails in three distinguishable ways: the URL is unreachable, the response is not JSON, and the JSON is valid but a required field is missing.

Define a hierarchy: one base exception for the import, one subclass per failure mode, and at least one readonly property on the subclasses that a caller needs in order to react. Write the fetch function so each failure mode throws the matching class, and wrap the transport failure in your own type with the original exception attached as previous.

Then write a caller that retries once on the unreachable case, reports the response problem without retrying, and lets a missing field escape. Use a single catch list, and put the branches in the order that makes the retry logic correct.

Show the solution and the reasoning

Start with the hierarchy, because it decides what the caller can express. The base class exists so a caller can handle every import failure at once; each subclass exists so a caller can handle one of them alone.

<?php
declare(strict_types=1);

class ImportFailed extends RuntimeException
{
}

final class FeedUnreachable extends ImportFailed
{
    public function __construct(public readonly string $url, ?Throwable $previous = null)
    {
        parent::__construct("Feed unreachable: {$url}", previous: $previous);
    }
}

final class MalformedFeed extends ImportFailed
{
    public function __construct(public readonly string $body, ?Throwable $previous = null)
    {
        parent::__construct('Feed response is not valid JSON', previous: $previous);
    }
}

final class MissingField extends ImportFailed
{
    public function __construct(public readonly string $field)
    {
        parent::__construct("Feed is missing required field {$field}");
    }
}

FeedUnreachable carries the URL and MalformedFeed carries the body, because those are the values a caller logs without re-deriving them. MissingField carries the field name, which is the one piece of information that makes the failure actionable at all.

function fetchFeed(string $url): array
{
    set_error_handler(static function (int $severity, string $message): never {
        throw new RuntimeException($message, $severity);
    });

    try {
        $body = file_get_contents($url);
    } catch (Throwable $cause) {
        throw new FeedUnreachable($url, previous: $cause);
    } finally {
        restore_error_handler();
    }

    if ($body === false) {
        throw new FeedUnreachable($url);
    }

    try {
        $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException $cause) {
        throw new MalformedFeed($body, previous: $cause);
    }

    foreach (['sku', 'price'] as $field) {
        if (!array_key_exists($field, $decoded)) {
            throw new MissingField($field);
        }
    }

    return $decoded;
}

The transport warning is converted to an exception inside the try, and the catch (Throwable $cause) translates it into the domain type. Catching Throwable here is correct and narrow in effect, because the only statements inside the try are the fetch and nothing else. If the block grew to include the JSON decoding, that same catch would start absorbing JsonException and mislabel a malformed response as unreachable.

$attempts = 0;

while (true) {
    $attempts++;

    try {
        $feed = fetchFeed('https://example.invalid/prices.json');
        echo "imported ", count($feed), " fields", PHP_EOL;
        break;
    } catch (FeedUnreachable $e) {
        if ($attempts >= 2) {
            printf("gave up after %d attempts: %s\n", $attempts, $e->getMessage());
            break;
        }
        echo "unreachable, retrying\n";
    } catch (MalformedFeed $e) {
        printf("bad response, not retrying: %s\n", $e->getMessage());
        break;
    }
}

Order matters in two ways here. FeedUnreachable and MalformedFeed are both subclasses of ImportFailed, so a branch for the base class placed first would swallow both and the retry distinction would vanish. And MissingField is deliberately not caught: the loop has nothing sensible to do about a missing column, so it travels to whatever handler the process installed, which is where the alerting lives.

A different reading of the same problem is defensible. You could return a result object with a status enum instead of throwing for the missing-field case, on the grounds that a schema mismatch is an expected outcome of importing third-party data. That version avoids exception control flow and makes the three outcomes part of the function's signature. It costs you the stack trace, which is the thing you want when the mismatch was not expected after all.

Progress

Mark this lesson done and it stays marked in this browser.