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 $content = file_get_contents("missing.txt"); var_dump($content); echo "still running\n"; undefinedFunction(); echo "never printed\n";
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 6Always 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 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";
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,PDOExceptionand your own.
A catch can list several types with |, and catch blocks are tried in order, so put specific types first:
<?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"; } }
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 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"; }
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:
| Exception | Throw when |
|---|---|
InvalidArgumentException | An argument is invalid |
DomainException, RangeException | A value is outside the allowed set or range |
LogicException | A bug in the calling code (e.g. methods called in the wrong order) |
RuntimeException | Something failed that only shows up at run time |
OutOfBoundsException | An invalid key or index was requested |
Since PHP 8, throw is an expression, so it fits in arrow functions, ?? and match:
<?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");
localhost
Fatal error: Uncaught RuntimeException: db_pass missing in main.php:7
Stack trace:
#0 {main}
thrown in main.php on line 7Custom 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 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()); }
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 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"; }
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 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");
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.
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