Errors and Exceptions

Things go wrong: files are missing, input is invalid, databases are down. PHP reports problems in two ways — older-style warnings and notices, which let the script continue, and exceptions, which stop the current code until something catches them. This lesson covers both, with try/catch/finally, custom exceptions and a safety net for anything uncaught.

Warnings and errors

Many built-in functions signal problems with a warning and a return value like false, and carry on. Serious problems — calling an undefined function, a type mismatch — throw an Error exception and stop the script if not caught:

PHP
<?php

$content = file_get_contents("missing.txt");
var_dump($content);
echo "still running\n";
undefinedFunction();
echo "never printed\n";
Output
Warning: file_get_contents(missing.txt): Failed to open stream: No such file or directory in main.php on line 3
bool(false)
still running

Fatal error: Uncaught Error: Call to undefined function undefinedFunction() in main.php:6
Stack trace:
#0 {main}
  thrown in main.php on line 6

Always check the return values of functions that can fail, and never hide warnings with the @ operator — fix the cause instead.

try and catch

Code that might throw goes in a try block. If an exception is thrown, PHP jumps to the first catch block whose type matches:

PHP
<?php

function divide(int $a, int $b): float
{
    return intdiv($a, $b);
}

foreach ([[10, 2], [7, 0], [9, 3]] as [$a, $b]) {
    try {
        $result = divide($a, $b);       // may throw: nothing is printed yet
        echo "$a / $b = $result\n";
    } catch (DivisionByZeroError $e) {
        echo "$a / $b: ", $e->getMessage(), "\n";
    }
}
echo "All done.\n";
Output
10 / 2 = 5
7 / 0: Division by zero
9 / 3 = 3
All done.

Notice that the result is calculated before anything is echoed. With echo "$a / $b = ", divide($a, $b), the first part would already be printed when the exception interrupts the statement, leaving a half-written line. The exception object carries details: getMessage(), getCode(), getFile(), getLine(), getTrace() and getPrevious().

The exception hierarchy

Everything that can be thrown implements Throwable, which has two branches:

  • Error — problems in the code itself: TypeError, ValueError, ArgumentCountError, DivisionByZeroError, UnhandledMatchError. Usually bugs to fix, not conditions to handle.
  • Exception — conditions a program can expect and handle: RuntimeException, InvalidArgumentException, JsonException, PDOException and your own.

A catch can list several types with |, and catch blocks are tried in order, so put specific types first:

PHP
<?php

function parseAge(string $input): int
{
    if (!is_numeric($input)) {
        throw new InvalidArgumentException("'$input' is not a number");
    }
    $age = (int) $input;
    if ($age < 0 || $age > 130) {
        throw new RangeException("Age $age is out of range");
    }
    return $age;
}

foreach (["34", "abc", "200"] as $input) {
    try {
        $age = parseAge($input);
        echo "Age: $age\n";
    } catch (InvalidArgumentException | RangeException $e) {
        echo get_class($e), ": ", $e->getMessage(), "\n";
    } catch (Throwable $e) {
        echo "Unexpected: ", $e->getMessage(), "\n";
    }
}
Output
Age: 34
InvalidArgumentException: 'abc' is not a number
RangeException: Age 200 is out of range

finally

A finally block always runs — after success, after a caught exception, and even when an exception propagates further up. It's for cleanup:

PHP
<?php

function process(bool $fail): void
{
    echo "lock acquired\n";
    try {
        if ($fail) {
            throw new RuntimeException("processing failed");
        }
        echo "work done\n";
    } finally {
        echo "lock released\n";
    }
}

process(false);
try {
    process(true);
} catch (RuntimeException $e) {
    echo "caught: ", $e->getMessage(), "\n";
}
Output
lock acquired
work done
lock released
lock acquired
lock released
caught: processing failed

Throwing exceptions

Throw when a function is asked to do something it can't. Pick the most specific built-in type, or make your own:

ExceptionThrow when
InvalidArgumentExceptionAn argument is invalid
DomainException, RangeExceptionA value is outside the allowed set or range
LogicExceptionA bug in the calling code (e.g. methods called in the wrong order)
RuntimeExceptionSomething failed that only shows up at run time
OutOfBoundsExceptionAn invalid key or index was requested

Since PHP 8, throw is an expression, so it fits in arrow functions, ?? and match:

PHP
<?php

$config = ['db_host' => 'localhost'];

$host = $config['db_host'] ?? throw new RuntimeException("db_host missing");
echo $host, "\n";
$pass = $config['db_pass'] ?? throw new RuntimeException("db_pass missing");
Output
localhost

Fatal error: Uncaught RuntimeException: db_pass missing in main.php:7
Stack trace:
#0 {main}
  thrown in main.php on line 7

Custom exceptions

Create your own exception classes when callers need to catch a specific problem from your code. Extend a suitable built-in exception and add any useful data:

PHP
<?php

final class InsufficientFundsException extends RuntimeException
{
    public function __construct(public readonly float $balance, public readonly float $requested)
    {
        parent::__construct(sprintf("Cannot withdraw %.2f; balance is %.2f", $requested, $balance));
    }

    public function shortfall(): float
    {
        return $this->requested - $this->balance;
    }
}

final class Account
{
    public function __construct(private float $balance) {}

    public function withdraw(float $amount): void
    {
        if ($amount > $this->balance) {
            throw new InsufficientFundsException($this->balance, $amount);
        }
        $this->balance -= $amount;
    }
}

try {
    new Account(50)->withdraw(80);
} catch (InsufficientFundsException $e) {
    echo $e->getMessage(), "\n";
    printf("Short by %.2f\n", $e->shortfall());
}
Output
Cannot withdraw 80.00; balance is 50.00
Short by 30.00

Rethrowing and wrapping

A low-level exception often makes more sense to the caller wrapped in a higher-level one. Pass the original as the $previous argument so the full chain of causes is kept:

PHP
<?php

function loadSettings(string $file): array
{
    try {
        return json_decode(file_get_contents($file) ?: '', true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException $e) {
        throw new RuntimeException("Settings file '$file' is not valid JSON", previous: $e);
    }
}

file_put_contents("settings.json", "{ broken json ");
try {
    loadSettings("settings.json");
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
    echo "  caused by: ", $e->getPrevious()?->getMessage(), "\n";
}
Output
Settings file 'settings.json' is not valid JSON
  caused by: Syntax error

To log an exception and let it continue upwards, catch it and write throw $e; — the original stack trace is preserved.

A global safety net

set_exception_handler registers a function for exceptions nothing caught. Web applications use it to log the details and show visitors a friendly error page instead of a stack trace. set_error_handler can turn warnings into exceptions so they can't be ignored:

PHP
<?php

set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
    throw new ErrorException($message, 0, $severity, $file, $line);
});

set_exception_handler(function (Throwable $e): void {
    echo "Sorry, something went wrong. (", get_class($e), ": ", $e->getMessage(), ")\n";
});

try {
    $x = [1, 2, 3][10];
} catch (ErrorException $e) {
    echo "Caught warning as exception: ", $e->getMessage(), "\n";
}

throw new LogicException("Unhandled problem");
Output
Caught warning as exception: Undefined array key 10
Sorry, something went wrong. (LogicException: Unhandled problem)

Frameworks install handlers like these for you.

Exceptions or return values?

Use exceptions for situations that are genuinely exceptional and that the immediate caller usually can't fix: a broken configuration, a database outage, a violated rule. For expected outcomes — "no user with that email", "form input invalid" — returning null, false or a result object is often simpler for the caller.

Try it yourself

Parse each line as name:age, collecting errors instead of stopping at the first bad line.

Try it yourself · main.php
Edit the code, copy it into main.php, then run it with php main.php (PHP 8.5). How to install PHP
Output
Ana is 34
Eve is 29
3 errors:
  'Ben': expected name:age
  'Cleo:abc': age must be a non-negative whole number
  'Dev:-5': age must be a non-negative whole number