PHP Try Catch Finally
When something goes wrong in a PHP script — a missing file, an invalid argument, a failed database query — you have a choice: let the script crash with a fatal error, or handle the problem gracefully. PHP’s try, catch, and finally blocks are the tools for the second option. They let you isolate risky code, respond to specific failure types, and guarantee that cleanup code always runs, no matter what happens.
Overview: How Try/Catch/Finally Works
PHP uses an exception-based error handling model for most modern error conditions. Instead of a function returning a special value like false or -1 to signal failure, it throws an object — an instance of a class implementing the built-in Throwable interface. When an exception is thrown, PHP immediately stops executing the current line and starts unwinding the call stack, looking for a catch block that can handle an object of that type. If it finds one, execution jumps there. If it doesn’t find one anywhere up the call stack, PHP reports an uncaught exception fatal error and the script terminates.
A try block marks a region of code as “this might fail.” One or more catch blocks follow it, each declaring the exception type(s) it knows how to handle. An optional finally block comes last, and its code runs every single time the try block finishes — whether it completed normally, threw an exception that was caught, threw an exception that wasn’t caught, or even hit a return statement. This makes finally the natural place for cleanup: closing file handles, releasing database connections, or releasing locks.
It helps to know PHP’s exception hierarchy. Since PHP 7, there are two main branches under the Throwable interface: Exception (for problems your application logic should anticipate, like invalid input) and Error (for problems the engine itself detects, like calling a method on null or passing the wrong type to a function). Both implement Throwable, but Exception and Error do not extend each other — a catch (Exception $e) block will never catch a TypeError, because TypeError extends Error, not Exception. This distinction trips up a lot of developers, and we’ll revisit it below.
Syntax
try {
// code that might throw an exception
} catch (SpecificException $e) {
// handle one exception type
} catch (AnotherException | YetAnotherException $e) {
// handle multiple types with one block (PHP 8+)
} finally {
// always runs, whether an exception was thrown or not
}
try— wraps the code you want to monitor for exceptions. Required.catch (Type $variable)— catches any thrown object whose class matchesType(or a subclass of it). You can list several types separated by|to handle them identically. There can be zero or morecatchblocks.$e(or any variable name) — holds the caught exception object inside the block. You can call methods on it likegetMessage(),getCode(),getFile(),getLine(), andgetTraceAsString().finally— optional block that always executes after the try/catch logic finishes, even if areturn,break, or uncaught exception is involved.
A try block must have at least one catch or a finally block — you cannot have a bare try with nothing after it.
Examples
Example 1: Basic try/catch/finally
<?php
function divide(int $a, int $b): float {
return $a / $b;
}
try {
echo "Attempting division...\n";
$result = divide(10, 0);
echo "Result: $result\n";
} catch (DivisionByZeroError $e) {
echo "Caught error: " . $e->getMessage() . "\n";
} finally {
echo "Division attempt finished.\n";
}
echo "Program continues.\n";
Output:
Attempting division...
Caught error: Division by zero
Division attempt finished.
Program continues.
Dividing by zero with the / operator throws a DivisionByZeroError in PHP 8+. Because the type matches the catch clause, execution jumps there instead of crashing the script. Notice that finally still runs after the catch block, and the script continues normally afterward — the exception did not stop the whole program, only the try block.
Example 2: Multiple catch types and multi-catch syntax
<?php
function parseAge(string $input): int {
if (!is_numeric($input)) {
throw new InvalidArgumentException("'$input' is not numeric.");
}
$age = (int) $input;
if ($age < 0 || $age > 150) {
throw new RangeException("Age $age is out of valid range.");
}
return $age;
}
$inputs = ['34', '-5', 'abc'];
foreach ($inputs as $input) {
try {
$age = parseAge($input);
echo "Parsed age: $age\n";
} catch (InvalidArgumentException | RangeException $e) {
echo "Validation failed for '$input': " . $e->getMessage() . "\n";
}
}
Output:
Parsed age: 34
Validation failed for '-5': Age -5 is out of valid range.
Validation failed for 'abc': 'abc' is not numeric.
Here parseAge() throws two different exception types depending on what went wrong. Since PHP 8.0, a single catch clause can list multiple types separated by |, so both are handled the same way without duplicating code. Each iteration of the loop has its own try/catch, so one bad input doesn’t stop the others from being processed.
Example 3: Custom exceptions and cleanup with finally
<?php
class InsufficientFundsException extends Exception {
public function __construct(
private readonly float $shortfall,
string $message
) {
parent::__construct($message);
}
public function getShortfall(): float {
return $this->shortfall;
}
}
class Account {
public function __construct(private float $balance) {}
public function withdraw(float $amount): void {
if ($amount > $this->balance) {
throw new InsufficientFundsException(
$amount - $this->balance,
"Cannot withdraw $amount, balance is only {$this->balance}."
);
}
$this->balance -= $amount;
}
}
$account = new Account(100.0);
try {
echo "Starting withdrawal...\n";
$account->withdraw(250.0);
echo "Withdrawal succeeded.\n";
} catch (InsufficientFundsException $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Shortfall: " . $e->getShortfall() . "\n";
} finally {
echo "Closing account session.\n";
}
Output:
Starting withdrawal...
Error: Cannot withdraw 250, balance is only 100.
Shortfall: 150
Closing account session.
This example defines a custom exception class that extends the built-in Exception class and adds its own data (the shortfall amount) via a promoted, readonly constructor property. Custom exceptions like this let you attach exactly the context a caller needs to react intelligently, instead of parsing a plain string message. The finally block simulates cleanup (like closing a session or connection) that must happen whether the withdrawal succeeds or fails.
How It Works Step by Step (Under the Hood)
When PHP executes a try block, the Zend Engine registers an exception handler scope for that block before running the code inside it. As execution proceeds normally, nothing special happens. The moment a throw statement (or an internal engine operation like dividing by zero) creates a Throwable object, PHP does the following:
- It immediately halts execution at that exact line — code after the
throwin the same block never runs. - It walks up through the enclosing
catchblocks of the current try statement, in the order they’re written, checking whether the thrown object is an instance of (or subclass of) each declared type. - If a matching
catchis found, the exception object is bound to the catch variable and that block’s code runs. - If no
catchin the current try matches, PHP propagates the exception up to the calling function’s try/catch (if any), continuing outward through the call stack — this is called stack unwinding. - Regardless of whether the exception was caught locally, caught further up, or never caught at all, any
finallyblock attached to a try the stack unwinds through is executed before unwinding continues. - If the exception reaches the top of the call stack with no matching
catchanywhere, PHP emits an uncaught exception fatal error and the script ends.
This is also why finally is so reliable: it isn’t just “the code after catch,” it’s wired into the engine’s stack-unwinding mechanism itself, which is what lets it run even when a return or an uncaught exception is in play.
Common Mistakes
Mistake 1: Returning from finally silently overrides the try’s return value
<?php
function riskyOperation(): string {
try {
return "try result";
} finally {
return "finally result";
}
}
echo riskyOperation();
You might expect this to print try result, but it actually prints finally result. If a finally block contains its own return, it completely discards whatever the try or catch block was about to return (or even an exception that was about to propagate). This is legal PHP, but it’s a classic source of confusing bugs because the override happens silently.
<?php
function riskyOperation(): string {
try {
return "try result";
} finally {
echo "Cleaning up...\n";
}
}
echo riskyOperation();
The fix is simple: never put a return (or break/continue) inside finally. Use it only for side effects like logging or closing resources, and let try/catch control the return value.
Mistake 2: Catching Exception but missing Error types
<?php
function addNumbers(int $a, int $b): int {
return $a + $b;
}
try {
echo addNumbers(5, "not a number");
} catch (Exception $e) {
echo "Caught: " . $e->getMessage();
}
Passing a non-numeric string where an int is expected causes PHP to throw a TypeError. Because TypeError extends Error, not Exception, the catch (Exception $e) block never matches — the error propagates uncaught and the script terminates with a fatal error. Developers coming from languages where “Exception” means “anything that can be thrown” fall into this trap constantly in PHP.
<?php
function addNumbers(int $a, int $b): int {
return $a + $b;
}
try {
echo addNumbers(5, "not a number");
} catch (Throwable $e) {
echo "Caught: " . $e->getMessage();
}
Catching Throwable instead of Exception catches both branches of the hierarchy. Use Throwable at boundaries like top-level request handlers or logging middleware where you genuinely want to catch anything; inside normal business logic, prefer catching the specific exception types you know how to recover from.
Best Practices
- Catch the most specific exception type you can meaningfully handle; avoid blanket
catch (Exception $e)unless you truly intend to handle every possible exception the same way. - Never put
return,break, orcontinueinside afinallyblock — it silently overrides the outcome of the try/catch. - Use
finallyonly for cleanup that must always happen: closing file handles, database connections, or releasing locks. - Create custom exception classes for domain-specific errors so callers can catch them by type and access rich context (like the
getShortfall()method above) instead of parsing message strings. - Order multiple
catchblocks from most specific to least specific — PHP checks them top to bottom and uses the first match. - Don’t use exceptions for routine control flow (like ending a loop); reserve them for genuinely exceptional conditions.
- Log the exception’s
getMessage(),getFile(), andgetLine()(or the whole exception via a logger) rather than silently swallowing it in an emptycatchblock.
Practice Exercises
- Write a function
safeSqrt(float $n): floatthat throws anInvalidArgumentExceptionif$nis negative, and returnssqrt($n)otherwise. Call it inside a try/catch that prints either the result or the error message, and use afinallyblock to print"Calculation attempt complete."every time. - Create a custom exception class
StockUnavailableExceptionthat stores a product name and the quantity requested. Write a functionorderProduct(string $name, int $qty, int $stock)that throws it when$qty > $stock. Catch the exception and print a message using the stored product name and quantity. - Write a loop that processes an array of strings meant to be integers (e.g.
['10', '20', 'oops', '5']) usingintdiv(100, (int) $value). Wrap each iteration in its own try/catch to handle aDivisionByZeroErrorif a value converts to0, so one bad entry doesn’t stop the rest from processing. What is the output for the sample array?
Summary
trywraps code that might fail;catchhandles specific thrown types;finallyalways runs, whether or not an exception occurred.- PHP’s throwables split into
Exception(application-level problems) andError(engine-level problems likeTypeErrorandDivisionByZeroError) — both implementThrowablebut neither extends the other. - A single
catchclause can list multiple types with|to handle them identically. - Custom exception classes let you attach structured context to a failure instead of just a string message.
- Never
returnfrom afinallyblock — it silently overrides the try/catch’s intended result. - Uncaught exceptions propagate up the call stack until they either hit a matching
catchor reach the top and produce a fatal error.
