Exceptions (try / catch / finally)
Exceptions are PHP's mechanism for interrupting normal execution when something goes wrong, and handing control to code that's explicitly prepared to deal with it. Instead of a function returning a special "failure" value that every caller has to remember to check, it throws an object describing what went wrong, and execution jumps straight to the nearest matching catch block — skipping everything in between, no matter how deeply nested the call stack is.
The basic try / catch shape
Catching a single exception type
<?php
function withdraw(float $balance, float $amount): float
{
if ($amount > $balance) {
throw new \RuntimeException('Insufficient funds');
}
return $balance - $amount;
}
try {
$newBalance = withdraw(100.0, 150.0);
echo "New balance: {$newBalance}\n";
} catch (\RuntimeException $e) {
echo 'Transaction failed: ', $e->getMessage(), "\n";
}Transaction failed: Insufficient funds
withdraw() never had to return a sentinel value like -1 or false to signal a problem — its return type stays a clean float in the success case. The failure path is handled entirely by the throw/catch pair, which keeps the "happy path" logic readable and makes the failure impossible to accidentally ignore (an uncaught exception halts the script loudly, rather than a false return value silently propagating).
Catching multiple exception types with |
Since PHP 7.1, a single catch block can list several exception types separated by |, and it runs if the thrown object matches any of them. This is useful when different failure types should be handled identically, without resorting to catching an overly broad common ancestor.
One handler for two unrelated exception types
<?php
function loadConfig(string $path): array
{
if (!file_exists($path)) {
throw new \InvalidArgumentException("Config file not found: {$path}");
}
$contents = file_get_contents($path);
$data = json_decode($contents, true, flags: JSON_THROW_ON_ERROR);
return $data;
}
try {
$config = loadConfig('/etc/app/config.json');
} catch (\InvalidArgumentException | \JsonException $e) {
// Both a missing file and malformed JSON are "bad configuration"
// as far as this call site is concerned.
echo 'Could not load configuration: ', $e->getMessage(), "\n";
}finally always runs
A finally block attached to try runs after the try/catch logic completes, regardless of whether an exception was thrown, caught, or neither. Critically, it also runs when the try or catch block contains a return statement — PHP evaluates the return value first, then runs finally, and only then actually returns.
finally running after an early return
<?php
function readAndClose(string $path): string
{
$handle = fopen($path, 'r');
try {
return fread($handle, filesize($path));
} finally {
// Runs even though the try block already returned above.
fclose($handle);
echo "Handle closed\n";
}
}
file_put_contents('/tmp/note.txt', 'hello');
echo readAndClose('/tmp/note.txt'), "\n";Handle closed hello
This makes finally the right place for cleanup that must happen no matter what — closing a file handle, releasing a database connection, or rolling back a lock — instead of duplicating that cleanup code once for the success path and again for every catch block.
Catching \Throwable as a last resort
\\Throwable is the interface implemented by both \\Exception and \\Error (PHP's other hierarchy, covering things like TypeError and DivisionByZeroError). Catching it means "handle absolutely anything that could go wrong here," which is appropriate at true boundaries — the outermost layer of a request handler, a background job runner, a CLI command's entry point — where the alternative is an unhandled crash, but is usually too broad for ordinary business logic.
A top-level safety net
<?php
function handleRequest(callable $action): void
{
try {
$action();
} catch (\Throwable $e) {
// Whatever it was -- Exception, Error, or a custom subtype --
// log it and show a safe response instead of crashing.
error_log($e->getMessage());
http_response_code(500);
echo 'Something went wrong.';
}
}Nesting try / catch
try/catch blocks can nest, and an inner catch only intercepts exceptions raised within its own try. If an inner block doesn't catch a type it isn't looking for, the exception keeps propagating outward to the next enclosing try — including one in a calling function further up the stack.
An inner catch handling one type, letting others escape
<?php
function parseAmount(string $raw): float
{
try {
if (!is_numeric($raw)) {
throw new \ValueError("Not a number: {$raw}");
}
return (float) $raw;
} catch (\TypeError $e) {
// This inner block only handles TypeError -- a ValueError
// thrown above is not caught here and keeps propagating.
return 0.0;
}
}
try {
parseAmount('not-a-number');
} catch (\ValueError $e) {
echo 'Caught at the outer level: ', $e->getMessage(), "\n";
}Caught at the outer level: Not a number: not-a-number
throwraises an object; execution jumps to the nearest matchingcatch, skipping everything in between.catch (TypeA | TypeB $e)handles either type with one block.finallyalways executes, even after areturninsidetryorcatch, making it the right place for guaranteed cleanup.Catch specific exception types close to where you can actually recover; reserve
\Throwablefor top-level safety nets.An uncaught exception in an inner
trykeeps propagating to outertryblocks or, eventually, crashes the script.