Errors & exceptions
The Throwable hierarchy, why PHP has both errors and exceptions, catching and rethrowing, finally, and reading a stack trace.
PHP has two failure traditions that finally met in PHP 7. Old-style errors (warnings, notices, fatals) came from C; exceptions came from object-oriented PHP. Today almost everything that used to be a fatal error is an object you can catch.
The hierarchy
Throwable (interface)
βββ Error engine failures β usually YOUR bug
β βββ TypeError wrong type passed or returned
β βββ ValueError right type, impossible value
β βββ ArithmeticError
β β βββ DivisionByZeroError
β βββ ArgumentCountError
β βββ AssertionError
β βββ UnhandledMatchError
βββ Exception conditions your program should handle
βββ ErrorException
βββ RuntimeException
β βββ OutOfBoundsException
β βββ UnexpectedValueException
βββ LogicException
β βββ InvalidArgumentException
β βββ DomainException
β βββ LengthException
βββ JsonException, β¦<?php
foreach ([fn() => 1 % 0, fn() => strlen(), fn() => array_chunk([1], 0)] as $f) {
try {
$f();
} catch (\Throwable $e) {
printf("%-22s %s\n", get_debug_type($e), $e->getMessage());
}
}Catching
<?php
function divide(int $a, int $b): float {
if ($b === 0) {
throw new InvalidArgumentException("Cannot divide $a by zero");
}
return $a / $b;
}
foreach ([[10, 2], [10, 0]] as [$a, $b]) {
try {
echo divide($a, $b), "\n";
} catch (InvalidArgumentException $e) {
echo 'Caught: ', $e->getMessage(), "\n";
}
}Catch several types with |, and omit the variable when you do not need it.
<?php
try {
throw new DivisionByZeroError('boom');
} catch (TypeError | DivisionByZeroError $e) {
echo get_debug_type($e), ': ', $e->getMessage(), "\n";
}
try {
json_decode('{bad', flags: JSON_THROW_ON_ERROR);
} catch (JsonException) { // no variable needed
echo "That was not JSON\n";
}Order matters: the first matching catch wins, so list the specific types before the general ones.
<?php
try {
throw new InvalidArgumentException('specific');
} catch (InvalidArgumentException $e) {
echo "specific handler\n";
} catch (LogicException $e) {
echo "never reached β InvalidArgumentException matched first\n";
}finally
finally runs whether the try succeeded, threw, or returned. It is for cleanup.
<?php
function work(bool $fail): string {
try {
if ($fail) {
throw new RuntimeException('failed');
}
return 'ok';
} catch (RuntimeException $e) {
return 'recovered';
} finally {
echo " cleanup ran\n";
}
}
echo work(false), "\n";
echo work(true), "\n";β οΈ A return inside finally overrides the return in the try β including overriding an exception on its way out, which makes it vanish silently. Never return from finally.
Throwing well
An exception should say what went wrong, carry the data that explains it, and preserve the cause.
<?php
final class ConfigError extends RuntimeException {
public function __construct(
public readonly string $key,
?Throwable $previous = null,
) {
parent::__construct("Missing config key: $key", 0, $previous);
}
}
function loadConfig(array $cfg, string $key): string {
return $cfg[$key] ?? throw new ConfigError($key); // throw is an expression
}
try {
try {
loadConfig([], 'database.host');
} catch (ConfigError $e) {
throw new RuntimeException('Could not start', previous: $e);
}
} catch (RuntimeException $e) {
echo $e->getMessage(), "\n";
echo ' caused by: ', $e->getPrevious()?->getMessage(), "\n";
echo ' key: ', $e->getPrevious()?->key, "\n";
}Since PHP 8, throw is an expression β usable in ??, ?:, arrow functions and match arms.
Reading a trace
<?php
function level3(): void { throw new RuntimeException('deep failure'); }
function level2(): void { level3(); }
function level1(): void { level2(); }
try {
level1();
} catch (RuntimeException $e) {
echo get_debug_type($e), ': ', $e->getMessage(), "\n";
echo 'thrown at line ', $e->getLine(), "\n";
foreach ($e->getTrace() as $i => $frame) {
printf(" #%d %s() line %s\n", $i, $frame['function'] ?? '?', $frame['line'] ?? '?');
}
}Read a trace bottom-up: the last frame is where it started, the first frame is where it blew up.
Warnings are not exceptions
fopen() on a missing file emits a warning and returns false. Warnings do not stop the script and are not catchable β unless you convert them.
<?php
set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
throw new ErrorException($msg, 0, $no, $file, $line);
});
try {
echo $undefinedVariable;
} catch (ErrorException $e) {
echo 'Converted to an exception: ', $e->getMessage(), "\n";
}
restore_error_handler();
echo "handler restored\n";π§ Scenario: A nightly import "succeeds" every night and imports nothing. The cause is a warning from fopen() that nobody sees because display_errors is off in production. Converting warnings to ErrorException in the bootstrap turns that silence into a stack trace on the first run.
The @ operator
@expr suppresses diagnostics from that expression. It is almost always the wrong tool: it hides the cause and costs performance.
<?php $data = ['a' => 1]; echo @$data['missing'] ?? 'nothing', "\n"; // works, but⦠echo $data['missing'] ?? 'nothing', "\n"; // �? already does it, without hiding anything
Reference: Exceptions and Predefined Exceptions in the PHP Manual.