PHP · Advanced

Exceptions and Error Handling in PHP

11 min readUpdated September 24, 2026Every example verified

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

 PHP · 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
<?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.5n/a199922.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
<?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
<?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, ValueError and DivisionByZeroError extend Error, not Exception, so the catch block is skipped and the program dies.

    Fix: Catch the specific Error subclass, or Throwable at 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) before catch (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 null and nothing is caught.

    Fix: Check with isset, ?? or array_key_exists first, or install an error handler that throws ErrorException.

Built-in throwables you will meet

ClassFamilyThrown when
InvalidArgumentExceptionException (Logic)an argument is unacceptable; thrown by your code
RangeException / RuntimeExceptionException (Runtime)a value is out of range / a runtime failure; thrown by your code
JsonExceptionExceptionjson_decode or json_encode fail with JSON_THROW_ON_ERROR
ErrorExceptionExceptiona warning converted by an error handler
TypeErrorErrorwrong argument or return type, wrong operand types
ValueErrorErrorright type, unusable value (str_repeat with a negative count)
DivisionByZeroErrorError/, % or intdiv by zero
UnhandledMatchErrorErrormatch 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.

 PHP · in practice
try {
    run($argv);
} catch (Throwable $e) {
    fwrite(STDERR, "error: " . $e->getMessage() . "\n");
    exit(1);
}

Key points

  • throw raises an exception object; the nearest matching catch up the call stack handles it.
  • Catch blocks are checked in order; specific types first, and A | B handles several.
  • finally always runs, even after return in the try block.
  • Exception and Error are separate trees under Throwable; catch Throwable only 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_handler with ErrorException converts 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.

Your program
<?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";
}
Input the program receives: 50 ↵ 80
Expected output: insufficient funds

Practise this

Open the PHP playground

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.

Progress is stored only in this browser.

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.