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 matches Type (or a subclass of it). You can list several types separated by | to handle them identically. There can be zero or more catch blocks.
  • $e (or any variable name) — holds the caught exception object inside the block. You can call methods on it like getMessage(), getCode(), getFile(), getLine(), and getTraceAsString().
  • finally — optional block that always executes after the try/catch logic finishes, even if a return, 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 throw in the same block never runs.
  • It walks up through the enclosing catch blocks 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 catch is found, the exception object is bound to the catch variable and that block’s code runs.
  • If no catch in 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 finally block 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 catch anywhere, 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, or continue inside a finally block — it silently overrides the outcome of the try/catch.
  • Use finally only 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 catch blocks 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(), and getLine() (or the whole exception via a logger) rather than silently swallowing it in an empty catch block.

Practice Exercises

  1. Write a function safeSqrt(float $n): float that throws an InvalidArgumentException if $n is negative, and returns sqrt($n) otherwise. Call it inside a try/catch that prints either the result or the error message, and use a finally block to print "Calculation attempt complete." every time.
  2. Create a custom exception class StockUnavailableException that stores a product name and the quantity requested. Write a function orderProduct(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.
  3. Write a loop that processes an array of strings meant to be integers (e.g. ['10', '20', 'oops', '5']) using intdiv(100, (int) $value). Wrap each iteration in its own try/catch to handle a DivisionByZeroError if a value converts to 0, so one bad entry doesn’t stop the rest from processing. What is the output for the sample array?

Summary

  • try wraps code that might fail; catch handles specific thrown types; finally always runs, whether or not an exception occurred.
  • PHP’s throwables split into Exception (application-level problems) and Error (engine-level problems like TypeError and DivisionByZeroError) — both implement Throwable but neither extends the other.
  • A single catch clause 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 return from a finally block — it silently overrides the try/catch’s intended result.
  • Uncaught exceptions propagate up the call stack until they either hit a matching catch or reach the top and produce a fatal error.