PHP error handling & exceptions
Throwable, Error, and Exception form two branches
Every value you can throw implements the Throwable interface (PHP 7.0+). It has two branches that both implement Throwable while neither extends the other:
Exceptioncovers application and domain problems you raise and handle yourself.Errorcovers engine-level problems: aTypeErrorfrom a type violation, aDivisionByZeroError, calling a method onnull. Before PHP 7 these were fatal errors. Since PHP 7 they arrive asErrorobjects, so you can catch them.
try {
intdiv(1, 0);
} catch (Throwable $e) { // covers both branches
echo get_class($e); // DivisionByZeroError
}
Wrong "catch (Exception) is my safety net, it catches anything thrown." An Error does not extend Exception; the two only share the Throwable interface. A catch (Exception) block misses a TypeError or a DivisionByZeroError, which then propagates uncaught. Right catch Throwable when you want both branches, or add a second catch (Error). One rule interviewers check: your own classes cannot implement Throwable directly, so you extend Exception or Error.
How try, catch, and finally behave
A try needs at least one catch or a finally. Catches match by instanceof and the first match wins, so order them specific before general. A broad catch (Exception) above a catch (RuntimeException) makes the second unreachable. A finally block always runs, after the try and after any matching catch, whether or not anything was thrown.
Several types can share one handler with the union syntax, available since PHP 7.1:
try {
$user = load($id);
} catch (NotFoundException | AccessDeniedException $e) {
return respond(404);
}
Since PHP 8.0 the caught variable is optional when you do not need it: catch (JsonException) { ... }.
Writing custom exceptions and chaining a cause
Extend Exception (or a fitting SPL subclass) and forward to the parent constructor, whose signature is (string $message = "", int $code = 0, ?Throwable $previous = null). The third argument records the original cause, which getPrevious() returns:
try {
$row = $pdo->query($sql);
} catch (PDOException $e) {
throw new RuntimeException('Could not load user', 0, $e); // keep the cause
}
If your subclass defines its own __construct, it must call parent::__construct($message, $code, $previous) or the message, code, and previous cause stay empty. The getters on Exception are final, so add your own accessors for extra context rather than overriding them.
Why warnings and notices escape try/catch
An E_WARNING or E_NOTICE (a failed fopen, an undefined array key) is not a Throwable. It flows through the older procedural error system governed by error_reporting, so a try/catch never sees it. Handling one with a catch block first requires converting it, which the senior notes cover.
A return in finally overrides and can swallow
finally runs after the try and any catch, even after a return. A return inside finally replaces the value the try or catch was about to return:
function test(): string {
try {
return 'try';
} finally {
return 'finally';
}
}
echo test(); // finally
The return 'try' is evaluated when reached, but its value is held while finally runs, and finally's own return wins. The same mechanism swallows a pending exception: if the try throws and reaches a finally that returns, the function returns normally and the throw is discarded. That is a frequent production bug, so keep cleanup in finally and returns out of it.
If the finally block throws instead, its exception propagates and the one already in flight becomes its $previous, so the original cause survives:
try {
throw new RuntimeException('original');
} finally {
throw new LogicException('from finally'); // original becomes getPrevious()
}
LogicException versus RuntimeException in the SPL tree
The SPL splits Exception into two families. LogicException marks a bug you could catch by reading the code, such as a bad argument or a call illegal in the current state. RuntimeException marks something knowable only while running, such as unexpected external input. The pairs candidates swap under pressure:
OutOfRangeExceptionis aLogicException;OutOfBoundsExceptionis aRuntimeException.DomainExceptionis aLogicException;RangeExceptionis its runtime counterpart.
Every SPL exception extends Exception, so none of them ever matches an Error. A catch (RuntimeException) will not see a TypeError.
Division by zero and engine errors by PHP version
The behavior changed across versions:
%andintdiv()throwDivisionByZeroErrorin both PHP 7 and 8.- The
/operator returnedfalsewith anE_WARNINGin PHP 7, and throwsDivisionByZeroErrorsince PHP 8.0. fdiv(1, 0)never throws; it returnsINF,-INF, orNANper IEEE-754.
DivisionByZeroError extends ArithmeticError, which extends Error, so catch (Exception) cannot reach it. Two version facts worth stating precisely: multi-catch (A | B) has existed since PHP 7.1, not 8.0. PHP 8.0 added the non-capturing catch and throw as an expression ($v = $x ?? throw new InvalidArgumentException()), plus ValueError and UnhandledMatchError.
Converting warnings to exceptions, and the two handlers
set_error_handler() intercepts procedural errors. Rethrow one as an ErrorException to make a warning behave like a catchable exception:
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) {
return false; // respect @ and the current error_reporting level
}
throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
});
Returning false hands control back to the standard handler; returning true swallows the error. The handler cannot intercept E_ERROR, E_PARSE, E_CORE_ERROR, or E_COMPILE_ERROR: those fatals bypass it, and you catch them afterward with register_shutdown_function() plus error_get_last(). set_exception_handler() runs for any uncaught Throwable that reaches global scope, then the script terminates, so you cannot resume. Throwing inside that handler produces a fatal error about an exception thrown without a stack frame.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.