Errors & Exceptions
Things go wrong in every real program. A customer types 0 people when splitting a bill, a settings file is broken, the database server is down. A good program does not crash with a scary message when this happens. It notices the problem, deals with it, and shows the visitor something sensible.
In this lesson you will catch problems with try, catch and finally, throw exceptions of your own, design small custom exception classes, and set up error handling that is helpful during development and safe in production.
try, catch and finally
An exception is an object that PHP throws when something goes wrong. Throwing stops the normal flow of the code. If some code higher up has caught that kind of exception, PHP jumps there. If nobody catches it, the script stops with a fatal error.
<?php
declare(strict_types=1);
function splitBill(int $total, int $people): int
{
return intdiv($total, $people);
}
try {
echo 'Each pays ₹' . splitBill(1200, 4) . "\n";
echo 'Each pays ₹' . splitBill(1200, 0) . "\n";
echo "This line never runs\n";
} catch (DivisionByZeroError $e) {
echo 'Problem: ' . $e->getMessage() . "\n";
} finally {
echo "Bill calculation finished\n";
}
echo "The script carries on\n";Each pays ₹300 Problem: Division by zero Bill calculation finished The script carries on
- The
tryblock holds code that might fail. - When
intdiv()throws, PHP skips the rest of thetryblock and runs the matchingcatchblock.$eis the exception object, andgetMessage()returns its message. - The
finallyblock runs in every case, whether or not something was thrown. It is the place for clean-up, such as closing a file. - After the
catch, the script continues normally.
Useful methods on every exception: getMessage(), getCode(), getFile(), getLine(), getTrace() and getPrevious().
Error and Exception: the family tree
Everything that can be thrown implements the Throwable interface, and it splits into two branches:
Throwable
Error mistakes in the code itself
TypeError wrong type passed (with strict_types)
ArgumentCountError too few arguments
ValueError right type, impossible value
ArithmeticError
DivisionByZeroError
Exception problems a program should expect
LogicException
InvalidArgumentException
RuntimeException
OutOfBoundsException
PDOException
JsonExceptionError usually means a bug: calling a function with the wrong type, or reading an uninitialized property. Fix the code rather than catching these. Exception means a problem that can happen even in correct code: bad user input, a missing file, a dropped database connection. These are the ones you catch and handle.
catch (Exception $e) catches every Exception but no Error. catch (Throwable $e) catches both; use it only at the very top level of an app, as shown later.
Throwing your own exceptions
Use throw when your function receives something it cannot work with. Pick the built-in class that describes the problem, or write your own by extending one. A custom exception can carry extra data:
<?php
declare(strict_types=1);
final class InsufficientStockException extends RuntimeException
{
public function __construct(
public readonly string $sku,
public readonly int $requested,
public readonly int $available,
) {
parent::__construct("Only $available left of $sku, but $requested were requested.");
}
}
function reserve(array &$stock, string $sku, int $qty): void
{
if ($qty <= 0) {
throw new InvalidArgumentException('Quantity must be at least 1.');
}
$available = $stock[$sku] ?? throw new OutOfBoundsException("Unknown product: $sku");
if ($qty > $available) {
throw new InsufficientStockException($sku, $qty, $available);
}
$stock[$sku] = $available - $qty;
}
$stock = ['TEA-250' => 5, 'PEN-10' => 40];
$orders = [['TEA-250', 2], ['TEA-250', 9], ['SOAP-1', 1], ['PEN-10', 0]];
foreach ($orders as [$sku, $qty]) {
try {
reserve($stock, $sku, $qty);
echo "Reserved $qty x $sku\n";
} catch (InsufficientStockException $e) {
echo 'Stock problem: ' . $e->getMessage() . ' (short by ' . ($e->requested - $e->available) . ")\n";
} catch (InvalidArgumentException | OutOfBoundsException $e) {
echo 'Bad order: ' . $e->getMessage() . "\n";
}
}Reserved 2 x TEA-250 Stock problem: Only 3 left of TEA-250, but 9 were requested. (short by 6) Bad order: Unknown product: SOAP-1 Bad order: Quantity must be at least 1.
Three modern features appear here:
- Several
catchblocks are checked from top to bottom, and the first matching one runs. Put more specific classes first. A | Bin onecatchhandles two exception types the same way.throwas an expression (PHP 8.0) lets you write$x ?? throw new ...to throw when a value is missing.
You can also leave out the variable when you do not need it: catch (JsonException) { ... }.
Wrapping exceptions with previous
Low-level errors, like a JSON syntax error, mean little to the rest of the app. Catch them and throw a clearer exception, passing the original as previous so no detail is lost:
<?php
declare(strict_types=1);
final class SettingsException extends RuntimeException
{
}
function loadSettings(string $path): array
{
if (!is_readable($path)) {
throw new SettingsException("Cannot read $path");
}
try {
return json_decode(file_get_contents($path), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new SettingsException("Settings file $path is not valid JSON", previous: $e);
}
}
try {
loadSettings(__DIR__ . '/settings.json'); // contains {"theme": "dark",}
} catch (SettingsException $e) {
echo $e->getMessage() . "\n";
echo 'Caused by: ' . $e->getPrevious()?->getMessage() . "\n";
}Settings file /var/www/app/settings.json is not valid JSON Caused by: Syntax error
Code that calls loadSettings() only needs to know about SettingsException, while a developer reading the log can still see the original cause.
Warnings are not exceptions
Many older PHP functions do not throw. They show a warning and return false. For example, file_get_contents() on a missing file warns Failed to open stream and returns false, and a try block will not catch that. Check return values of such functions, as in Reading & Writing Files. Newer APIs usually offer an exception mode: JSON_THROW_ON_ERROR for JSON and PDO::ERRMODE_EXCEPTION for databases.
Avoid the @ operator (as in @file_get_contents(...)), which hides warnings. Hidden problems are the hardest to debug.
Errors in development and in production
During development you want to see every problem. On a live site, error messages must never reach visitors, because they reveal file paths, SQL queries and sometimes passwords. Control this in php.ini:
| Setting | Development | Production |
|---|---|---|
error_reporting | E_ALL | E_ALL |
display_errors | On | Off |
log_errors | On | On |
error_log | optional | a file outside the web root |
Then add a last line of defence for anything nobody caught. set_exception_handler() registers a function that runs for any uncaught exception or error:
<?php
declare(strict_types=1);
set_exception_handler(function (Throwable $e): void {
error_log($e::class . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
http_response_code(500);
echo 'Sorry, something went wrong. Please try again later.';
});The visitor sees a polite message, and the full details go to the error log, where you can read them.
Common mistakes
- Catching everything and ignoring it. An empty
catch (Exception $e) {}hides bugs. At least log the problem. - Catching too early. Only catch where you can actually do something useful; otherwise let the exception travel up.
- Using exceptions for normal flow. "No search results" is not an exception; return an empty array.
- Leaving
display_errorson in production. Visitors and attackers see your internals.
Key points
tryruns risky code,catchhandles a matching exception, andfinallyalways runs.Throwablesplits intoError(bugs in code) andException(problems to expect and handle).throwyour own exceptions; extend built-in classes to add meaning and data.- Order
catchblocks from specific to general; useA | Bto share one handler. - Wrap low-level exceptions with
previous:to keep the original cause. - In production, set
display_errors=Off, log everything, and show visitors a friendly message.
Exercise
Write a function parseAge(string $input): int for a sign-up form. Throw an InvalidArgumentException with a clear message if the input is not a whole number, and a RangeException if the age is below 13 or above 120. Test it with '21', 'abc' and '9' in a loop that catches both exception types in one catch block and prints the messages.
Show solution
<?php
declare(strict_types=1);
function parseAge(string $input): int
{
$age = filter_var(trim($input), FILTER_VALIDATE_INT);
if ($age === false) {
throw new InvalidArgumentException("\"$input\" is not a whole number.");
}
if ($age < 13 || $age > 120) {
throw new RangeException('Age must be between 13 and 120.');
}
return $age;
}
foreach (['21', 'abc', '9'] as $input) {
try {
echo 'Age accepted: ' . parseAge($input) . "\n";
} catch (InvalidArgumentException | RangeException $e) {
echo 'Error: ' . $e->getMessage() . "\n";
}
}Age accepted: 21 Error: "abc" is not a whole number. Error: Age must be between 13 and 120.
filter_var() with FILTER_VALIDATE_INT returns false for text that is not a whole number, and the two exception types share one catch with |.
Quick Quiz
1. When does a finally block run?
finally is for clean-up code that must run in every case.
2. Which statement catches an Error such as TypeError?
Error and Exception are separate branches; Throwable covers both.
3. What should a live website do with an unexpected database exception?
Detailed messages can reveal secrets; log them and keep the visitor's message general.
4. Does try { $x = file_get_contents('missing.txt'); } catch (Exception $e) {} catch the missing file?
Many older functions warn and return false instead of throwing, so check their return value.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
