PHP · Advanced
Exceptions and Error Handling in PHP
In short: An exception is an object that interrupts normal flow when throw is reached; the nearest matching catch block handles it and finally runs either way. In PHP 8 both Exception and Error implement Throwable: engine faults such as TypeError and DivisionByZeroError are Errors, while application failures use Exception and its subclasses. Warnings and notices are not exceptions.
Failing loudly, recovering deliberately
A function that cannot do its job has two choices: return a special value such as false or -1 and hope the caller checks, or throw. Throwing is better when the failure is exceptional, because the caller cannot ignore it by accident: an uncaught exception stops the program with a message and a stack trace, and exit status 255. throw new InvalidArgumentException("quantity must be positive") creates an exception object and starts unwinding: PHP leaves the current function, then its caller, and so on, until it finds a try block whose catch matches.
try { ... } catch (SomeType $e) { ... } names the type it handles; the catch runs only for that class, its subclasses and, for interfaces, any implementing class. Several catch blocks are tried in order, so put the specific one first. A single block can handle several unrelated types with catch (TypeError | ValueError $e). Since PHP 8.0 the variable can be omitted, catch (JsonException), when the object is not needed. finally runs after the try and any catch, whether an exception occurred or not, and even when the try block executed return; it exists for cleanup such as closing a file or printing a footer.
Every throwable implements Throwable. Below it sit two trees. Exception is for conditions a program is expected to handle, and the standard library provides subclasses that describe the failure: InvalidArgumentException, RangeException, RuntimeException, JsonException. Error is for faults in the program itself: TypeError when an argument has the wrong type, ArgumentCountError, DivisionByZeroError from /, % and intdiv, ValueError when an argument is the right type but an unusable value, and UnhandledMatchError. Catching Exception therefore does not catch a TypeError; catch Throwable when you genuinely want everything, which is normally only at the top level of a script.
Define your own exception by extending an existing class: class InvalidReadingException extends InvalidArgumentException. Add properties for context, call parent::__construct($message), and callers can catch your type specifically while still catching it through the parent type. The object carries getMessage(), getCode(), getLine(), getFile(), getTraceAsString() and getPrevious(). The third constructor argument chains a previous exception, so throw new RuntimeException("cannot start", 0, $e) reports a high-level failure while preserving the original cause. Since PHP 8.0 throw is an expression, which allows $port = $config['port'] ?? throw new LogicException('no port');.
Warnings, notices and deprecations are older and separate. Reading an undefined array key or variable emits E_WARNING, prints a message to the error output and continues with null; no exception is involved and no catch sees it. set_error_handler installs a function that receives these events, and the common practice is to throw an ErrorException from it, which turns every warning into something a try block can catch. restore_error_handler puts the previous handler back. Fatal errors such as running out of memory cannot be caught at all.
Syntax
throw new InvalidArgumentException("message", 42); // code is optional
try {
risky();
} catch (InvalidArgumentException | RangeException $e) { // several types
echo $e->getMessage(), " ", $e->getCode(), " line ", $e->getLine();
} catch (Exception $e) { // broader, tried later
throw new RuntimeException("wrapped", 0, $e); // rethrow with the cause chained
} finally {
// always runs, even after return or an uncaught throw
}
class StockException extends RuntimeException
{
public function __construct(public readonly string $sku, string $message)
{
parent::__construct($message);
}
}
$value = $array["key"] ?? throw new LogicException("missing key"); // throw as expression (PHP 8.0)
set_error_handler(function (int $level, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $level, $file, $line); // warnings become exceptions
});Catch blocks are tested in order; list specific types before general ones. Exception does not catch Error; Throwable catches both.
Validating input with a custom exception
Sensor readings are parsed one per line; unparsable lines and out-of-range values throw different exceptions and are reported differently.
<?php
declare(strict_types=1);
class InvalidReadingException extends InvalidArgumentException
{
public function __construct(public readonly string $raw)
{
parent::__construct("not a reading: '$raw'");
}
}
function parseReading(string $line): float
{
$line = trim($line);
if (!is_numeric($line)) {
throw new InvalidReadingException($line);
}
$value = (float) $line;
if ($value < -50 || $value > 60) {
throw new RangeException("out of range: $value");
}
return $value;
}
$sum = 0.0;
$used = 0;
while (($line = fgets(STDIN)) !== false) {
try {
$sum += parseReading($line);
$used++;
} catch (InvalidReadingException $e) {
echo "skipped ", $e->raw, "\n";
} catch (RangeException $e) {
echo "rejected: ", $e->getMessage(), "\n";
}
}
echo "average of ", $used, ": ", $sum / $used, "\n";Input given to the program: 21.5 ↵ n/a ↵ 19 ↵ 99 ↵ 22.5
Output
skipped n/a rejected: out of range: 99 average of 3: 21
parseReading has one job and refuses bad input by throwing; it never prints. The loop decides what a failure means: an unparsable line is skipped with the raw text taken from the exception's own property, and an out-of-range value is reported with the message. The $used++ after the call never runs for a bad line because the throw leaves the try block first. The three good readings average to 21, and the program's exit status is 0 because every exception was caught.
finally, Error versus Exception, and throw as an expression
A finally block runs before a returned value is used, an engine Error is caught by type, and a missing setting throws inline.
<?php
function share(int $total, int $people): int
{
try {
return intdiv($total, $people);
} finally {
echo "share() finished\n";
}
}
try {
echo share(10, 4), "\n";
echo share(10, 0), "\n";
} catch (DivisionByZeroError $e) {
echo get_class($e), ": ", $e->getMessage(), "\n";
}
$settings = ["retries" => 3];
try {
$retries = $settings["retries"] ?? throw new LogicException("missing retries");
$timeout = $settings["timeout"] ?? throw new LogicException("missing timeout");
echo "unreachable\n";
} catch (LogicException $e) {
echo "config error: ", $e->getMessage(), "\n";
}
try {
strlen([]);
} catch (Exception $e) {
echo "Exception branch\n";
} catch (Error $e) {
echo "Error branch: ", get_class($e), "\n";
}Output
share() finished 2 share() finished DivisionByZeroError: Division by zero config error: missing timeout Error branch: TypeError
The first call returns 2, but finally prints before the value reaches echo, so the message comes first. The second call throws inside intdiv; finally still runs on the way out and then the catch reports the DivisionByZeroError. In the settings block the first ?? finds a value and the second does not, so the throw expression fires and the echo is never reached. Passing an array to strlen is a TypeError, which is an Error, so the Exception branch is skipped.
Turning warnings into exceptions and chaining causes
An error handler converts the undefined-key warning into an ErrorException, which is then wrapped in a higher-level exception with the cause attached.
<?php
set_error_handler(function (int $level, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $level, $file, $line);
});
function loadPort(array $config): int
{
try {
return (int) $config["port"];
} catch (ErrorException $e) {
throw new RuntimeException("cannot start server", 0, $e);
}
}
try {
echo loadPort(["port" => "8080"]), "\n";
echo loadPort(["host" => "localhost"]), "\n";
} catch (RuntimeException $e) {
echo $e->getMessage(), "\n";
echo " because: ", $e->getPrevious()->getMessage(), "\n";
}
restore_error_handler();Output
8080 cannot start server because: Undefined array key "port"
Without the handler, the second loadPort call would print a warning to the error stream and return 0, and the program would carry on with a nonsense port. With it, the warning becomes an ErrorException, loadPort catches that and throws a RuntimeException that says what failed at the level the caller cares about, while getPrevious() still reveals the original message. restore_error_handler removes the handler when it is no longer wanted.
Common mistakes
Catching Exception and expecting to catch a TypeError
Why it goes wrong:
TypeError,ValueErrorandDivisionByZeroErrorextendError, notException, so the catch block is skipped and the program dies.Fix: Catch the specific
Errorsubclass, orThrowableat the outermost level.PHP · fix<?php try { echo intdiv(1, 0); } catch (Throwable $e) { echo get_class($e), "\n"; }An empty catch block
Why it goes wrong:
catch (Exception $e) {}hides the failure completely; the program continues with missing data and fails somewhere far from the cause.Fix: Handle it (retry, use a default, report it) or let it propagate. If you must ignore it, say why in a comment and log the message.
Listing the general catch first
Why it goes wrong:
catch (Exception $e)beforecatch (RangeException $e)handles everything, so the specific block is never reached. PHP does not warn about this.Fix: Order catch blocks from most specific to most general.
Expecting try/catch to stop a warning
Why it goes wrong: Undefined variables, undefined array keys and similar emit warnings, not exceptions. The code continues with
nulland nothing is caught.Fix: Check with
isset,??orarray_key_existsfirst, or install an error handler that throwsErrorException.
Built-in throwables you will meet
| Class | Family | Thrown when |
|---|---|---|
| InvalidArgumentException | Exception (Logic) | an argument is unacceptable; thrown by your code |
| RangeException / RuntimeException | Exception (Runtime) | a value is out of range / a runtime failure; thrown by your code |
| JsonException | Exception | json_decode or json_encode fail with JSON_THROW_ON_ERROR |
| ErrorException | Exception | a warning converted by an error handler |
| TypeError | Error | wrong argument or return type, wrong operand types |
| ValueError | Error | right type, unusable value (str_repeat with a negative count) |
| DivisionByZeroError | Error | /, % or intdiv by zero |
| UnhandledMatchError | Error | match has no matching arm and no default |
Where you use this
A command-line tool that reads a file of records wants two levels of handling. Per record, a parsing function throws a specific exception and the loop catches it, reports the line number and continues, so one bad row does not discard a thousand good ones. At the top of the script, a single catch (Throwable $e) prints a short message to STDERR and exits with a non-zero status, so a shell pipeline or a scheduler notices the failure instead of receiving a stack trace on standard output. The same shape applies to web request handlers, where the outer catch becomes an error page.
try {
run($argv);
} catch (Throwable $e) {
fwrite(STDERR, "error: " . $e->getMessage() . "\n");
exit(1);
}Key points
throwraises an exception object; the nearest matchingcatchup the call stack handles it.- Catch blocks are checked in order; specific types first, and
A | Bhandles several. finallyalways runs, even afterreturnin the try block.ExceptionandErrorare separate trees underThrowable; catchThrowableonly at the top level.- Extend an existing exception class to make your own, and add properties for context.
- Pass the original exception as the third constructor argument to chain causes; read it with
getPrevious(). - Warnings are not exceptions;
set_error_handlerwithErrorExceptionconverts them. - An uncaught exception prints a fatal error and exits with status 255.
Try it yourself
withdraw currently allows the balance to go negative. Make it throw an InvalidArgumentException with the message insufficient funds when the amount exceeds the balance. The surrounding code already catches the exception and prints its message.
<?php
function withdraw(int $balance, int $amount): int
{
return $balance - $amount;
}
$balance = (int) trim(fgets(STDIN));
$amount = (int) trim(fgets(STDIN));
try {
echo withdraw($balance, $amount), "\n";
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
50 ↵ 80insufficient funds<?php
function withdraw(int $balance, int $amount): int
{
if ($amount > $balance) {
throw new InvalidArgumentException("insufficient funds");
}
return $balance - $amount;
}
$balance = (int) trim(fgets(STDIN));
$amount = (int) trim(fgets(STDIN));
try {
echo withdraw($balance, $amount), "\n";
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
Practise this
Related lessons
- Functions in PHPDefine PHP functions with typed parameters and return types, use default values, named arguments and variadics, and see why variables inside a function are.10 min
- JSON and Data Handling in PHPEncode PHP arrays to JSON and decode JSON into arrays, choose the right flags, avoid the list-versus-object trap, and turn raw input lines into structured.11 min
Frequently asked questions
What is the difference between Error and Exception in PHP?
Both implement Throwable and both can be caught, but they are separate class trees. Error and its subclasses (TypeError, ValueError, DivisionByZeroError, ArgumentCountError) signal faults in the program itself and are thrown by the engine. Exception and its subclasses signal conditions the program is expected to handle and are what your own code should throw. catch (Exception $e) does not catch an Error; use catch (Throwable $e) for both.
Does finally run after a return statement?
Yes. When the try block executes return, the finally block runs before the function actually returns to its caller, and the returned value is the one computed in the try block. If finally itself contains a return, that value replaces the original, which is confusing and best avoided.
How do I catch a PHP warning?
You cannot catch it directly, because a warning is not an exception. Install a handler with set_error_handler that throws an ErrorException built from the warning's message, level, file and line; from then on any warning becomes an exception that try/catch can handle. Alternatively prevent the warning by checking with isset, ?? or array_key_exists before reading.
How this page was checked. Every program on it was run with PHP 8.4 at build time by the publishing checks, and the output shown is what it printed. Running PHP inside the browser is not available yet, so the Run button is absent rather than pretending; copy the code and run it with PHP 8.4 locally.