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.
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.