Errors, Exceptions and Debugging

Article · 11:00

PHP has two failure families, and knowing which one you are looking at is most of debugging: an Error means something is wrong with the program, an Exception that something went wrong in it. Plus the warnings that quietly hide a real bug.

This lesson has 11 runnable examples. Press Try yourself! under any of them to open it in the editor.

Exceptions, Errors and Warnings

PHP has two failure systems that met in the middle: the old warning-and-continue model, and exceptions. Modern PHP code should use exceptions for everything and turn warnings into exceptions too, so nothing fails silently.

The hierarchy

PHP Example 1 16 lines
<?php
declare(strict_types=1);

// Throwable
//   Error              - engine-level: TypeError, ValueError,
//     |                  ArgumentCountError, DivisionByZeroError,
//     |                  ArithmeticError, AssertionError
//   Exception          - application-level
//     RuntimeException   - LogicException
//       OutOfBoundsException, OverflowException, RangeException
//     LogicException
//       InvalidArgumentException, DomainException, LengthException

var_dump(new TypeError('x') instanceof Throwable);
var_dump(new RuntimeException('x') instanceof Throwable);
var_dump(new TypeError('x') instanceof Exception);           // false!

Open this example in the editor and change it. Nothing you type is saved.

That last line is the one that catches people. catch (Exception $e) does not catch a TypeError, because Error and Exception are siblings. catch (Throwable $e) catches both.

The intended division: an Error means the program is wrong - a type mismatch, calling a method on null - and should usually crash so someone fixes it. An Exception means something the program anticipated - a file is missing, a request failed - and should be handled.

Within exceptions: LogicException means a bug in the caller (bad argument, invalid state), RuntimeException means something outside your control.

Catching precisely, and finally

PHP Example 2 20 lines
<?php
declare(strict_types=1);

function parse(string $json): array
{
    try {
        $value = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        return ['error' => 'not json: ' . $e->getMessage()];
    }

    if (!is_array($value)) {
        return ['error' => 'not an object'];
    }
    return $value;
}

print_r(parse('{"a":1}'));
print_r(parse('nope'));
print_r(parse('42'));

Open this example in the editor and change it. Nothing you type is saved.

Several types in one catch, and the variable is optional since PHP 8:

PHP Example 3 18 lines
<?php
declare(strict_types=1);

foreach (['12', 'x'] as $input) {
    try {
        if (!ctype_digit($input)) {
            throw new InvalidArgumentException("not a number: $input");
        }
        echo (int) $input, PHP_EOL;
    } catch (InvalidArgumentException | RangeException $e) {
        echo 'rejected: ', $e->getMessage(), PHP_EOL;
    } catch (Throwable) {                   // no variable needed
        echo 'something else', PHP_EOL;
    } finally {
        // Runs on success, on a caught exception, on an uncaught one,
        // and on `return` - which is what makes it right for cleanup.
    }
}

Open this example in the editor and change it. Nothing you type is saved.

finally runs whatever happens, and that includes overriding a return:

PHP Example 4 13 lines
<?php
declare(strict_types=1);

function surprising(): string
{
    try {
        return 'from try';
    } finally {
        return 'from finally';          // discards the above - never do this
    }
}

echo surprising(), PHP_EOL;             // from finally

Open this example in the editor and change it. Nothing you type is saved.

A return in a finally block swallows both the returned value and any exception on its way out. Use finally for cleanup only.

Your own exceptions, with a cause

PHP Example 5 42 lines
<?php
declare(strict_types=1);

final class ConfigurationError extends RuntimeException
{
    public function __construct(
        public readonly string $key,
        string $reason,
        ?Throwable $previous = null,
    ) {
        parent::__construct("configuration $key: $reason", 0, $previous);
    }
}

function loadPort(array $config): int
{
    if (!array_key_exists('port', $config)) {
        throw new ConfigurationError('port', 'is missing');
    }
    try {
        $port = filter_var($config['port'], FILTER_VALIDATE_INT,
            ['options' => ['min_range' => 1, 'max_range' => 65535]]);
        if ($port === false) {
            throw new UnexpectedValueException('out of range or not an integer');
        }
        return $port;
    } catch (UnexpectedValueException $e) {
        // The third argument keeps the original, so the trace shows both.
        throw new ConfigurationError('port', 'invalid', $e);
    }
}

foreach ([['port' => '5432'], [], ['port' => '99999']] as $config) {
    try {
        echo loadPort($config), PHP_EOL;
    } catch (ConfigurationError $e) {
        printf("%s (key=%s, caused by %s)%s",
            $e->getMessage(), $e->key,
            $e->getPrevious() === null ? 'nothing' : get_class($e->getPrevious()),
            PHP_EOL);
    }
}

Open this example in the editor and change it. Nothing you type is saved.

The $previous argument is the whole reason to wrap rather than rethrow: the caller gets a message in their own vocabulary, and the original cause is still in the chain for the log.

A custom exception carrying typed data - here $key - is worth the five lines. A handler that must parse the message string to find out which key failed is a handler that will break when someone improves the wording.

Reading an exception:

PHP Example 6 12 lines
<?php
declare(strict_types=1);

try {
    throw new RuntimeException('something failed', 42);
} catch (RuntimeException $e) {
    echo $e->getMessage(), PHP_EOL;         // something failed
    echo $e->getCode(), PHP_EOL;            // 42
    echo basename($e->getFile()), PHP_EOL;
    echo $e->getLine() > 0 ? 'has a line' : 'no line', PHP_EOL;
    echo count($e->getTrace()) >= 0 ? 'has a trace' : '', PHP_EOL;
}

Open this example in the editor and change it. Nothing you type is saved.

Never put $e->getMessage() into an HTTP response. It frequently contains a file path, a query, or a connection string. Log the full exception; show the user a reference.

Warnings are not exceptions

PHP Example 7 7 lines
<?php
declare(strict_types=1);

// This emits a warning and returns false. Execution continues, and
// $contents is false rather than a string.
$contents = @file_get_contents('/no/such/file');
var_dump($contents);                        // bool(false)

Open this example in the editor and change it. Nothing you type is saved.

A whole category of PHP functions behaves this way: file_get_contents, fopen, unlink, mkdir, most of the ext/standard file and network layer. The @ suppresses the warning and is nearly always the wrong tool, because it hides the only signal you had.

The right fix is to convert warnings into exceptions globally:

PHP Example 8 18 lines
<?php
declare(strict_types=1);

set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
    // error_reporting() is 0 for a suppressed call, so this respects @.
    if (!(error_reporting() & $severity)) {
        return false;
    }
    throw new ErrorException($message, 0, $severity, $file, $line);
});

try {
    file_get_contents('/no/such/file');
} catch (ErrorException $e) {
    echo 'caught as an exception: ', explode(':', $e->getMessage())[0], PHP_EOL;
}

restore_error_handler();

Open this example in the editor and change it. Nothing you type is saved.

With that handler installed at the top of the application, every warning becomes a catchable exception with a file and a line. It is four lines and it removes an entire class of silent failure.

The modern alternative, where the extension supports it, is to ask for exceptions directly:

PHP Example 9 24 lines
<?php
declare(strict_types=1);

// PDO: exceptions rather than silent false
//   $pdo = new PDO($dsn, $user, $pass, [
//       PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
//   ]);
//
// mysqli: the same
//   mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT);
//
// json: the flag
try {
    json_decode('nope', true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
    echo 'JsonException', PHP_EOL;
}

// intdiv and % already throw
try {
    intdiv(1, 0);
} catch (DivisionByZeroError $e) {
    echo 'DivisionByZeroError', PHP_EOL;
}

Open this example in the editor and change it. Nothing you type is saved.

PDO's ERRMODE_EXCEPTION has been the default since PHP 8, which removed the single most common source of "the query failed and nobody noticed".

A worked example

PHP Example 10 98 lines
<?php
declare(strict_types=1);

final class ValidationError extends InvalidArgumentException
{
    public function __construct(public readonly string $field, string $reason)
    {
        parent::__construct("$field $reason");
    }
}

final class ValidationFailed extends InvalidArgumentException
{
    /** @param list<ValidationError> $errors */
    public function __construct(public readonly array $errors)
    {
        parent::__construct(count($errors) . ' validation error(s)');
    }
}

/**
 * Collects every problem rather than stopping at the first, because a
 * form that reports one error at a time is a miserable form.
 */
function validate(array $record): array
{
    $errors = [];

    foreach (['reference', 'email'] as $field) {
        if (($record[$field] ?? '') === '') {
            $errors[] = new ValidationError($field, 'is required');
        }
    }

    $email = $record['email'] ?? '';
    if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
        $errors[] = new ValidationError('email', 'is not a valid address');
    }

    try {
        $amount = $record['amount'] ?? '';
        if (!is_string($amount) && !is_int($amount)) {
            throw new UnexpectedValueException('wrong type');
        }
        $pence = filter_var($amount, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
        if ($pence === false) {
            throw new UnexpectedValueException('not a positive whole number of pence');
        }
    } catch (UnexpectedValueException $e) {
        $errors[] = new ValidationError('amount', $e->getMessage());
        $pence = 0;
    }

    if ($errors !== []) {
        throw new ValidationFailed($errors);
    }

    return ['reference' => $record['reference'], 'email' => $email, 'pence' => $pence];
}

$records = [
    ['reference' => 'A-1', 'email' => 'aisha@example.com', 'amount' => '1999'],
    ['reference' => '',    'email' => 'broken',            'amount' => 'x'],
    ['reference' => 'A-3', 'email' => 'sam@example.com',   'amount' => '-5'],
];

foreach ($records as $index => $record) {
    try {
        $clean = validate($record);
        printf("record %d: ok, %s for %s%s",
            $index + 1, number_format($clean['pence'] / 100, 2), $clean['email'], PHP_EOL);
    } catch (ValidationFailed $e) {
        printf("record %d: %s%s", $index + 1, $e->getMessage(), PHP_EOL);
        foreach ($e->errors as $error) {
            printf("   - %s%s", $error->getMessage(), PHP_EOL);
        }
    } finally {
        // Always runs - the right place for a metric or a span.
    }
}

// And the warning-to-exception handler, so a missing file is a failure
// rather than a `false` that flows onwards.
set_error_handler(static function (int $severity, string $message): bool {
    if (!(error_reporting() & $severity)) {
        return false;
    }
    throw new ErrorException($message, 0, $severity);
});

try {
    $config = file_get_contents('/no/such/config.json');
    echo strlen((string) $config), PHP_EOL;
} catch (ErrorException $e) {
    echo 'config unreadable, using defaults', PHP_EOL;
} finally {
    restore_error_handler();
}

Open this example in the editor and change it. Nothing you type is saved.

Two decisions are worth naming. The validator collects into a list and throws once, carrying typed ValidationError objects rather than strings - so an HTTP layer can turn them into a field-keyed JSON response without parsing anything. And the finally restores the error handler, so an exception thrown inside the try cannot leave the handler installed for the rest of the request.

When it goes wrong

Symptom Cause
catch (Exception $e) missed a TypeError Error is a sibling; catch Throwable
A function returned false and nothing complained A warning, not an exception; install a handler
@ hid the only clue Suppression; remove it and handle the failure
The original cause is missing from the log No $previous when rethrowing
A finally swallowed the return value A return inside finally
A stack trace leaked a password getMessage() sent to the user; log it instead
A handler parsed the message string Put the data on the exception as a property
The error handler stayed installed Restore it in a finally

A check you can run

PHP Example 11 14 lines
<?php
declare(strict_types=1);

$bad = static function (): void {
    intdiv(1, 0);                       // DivisionByZeroError, which is an Error
};

try {
    $bad();
} catch (Exception $e) {
    echo 'caught as Exception', PHP_EOL;
} catch (Throwable $e) {
    echo 'the Exception arm missed it; Throwable caught ', get_class($e), PHP_EOL;
}

Open this example in the editor and change it. Nothing you type is saved.

It prints the second line. catch (Exception $e) never ran, because DivisionByZeroError is an Error, and Error and Exception are siblings rather than one extending the other.

Now delete the catch (Throwable) arm and run it again: the script dies with an uncaught DivisionByZeroError. That one word is the difference between a handler that covers your failures and one that covers half of them, and the half it misses is the half caused by your own bugs.