PHP Exceptions

An exception in PHP is an object that represents an error or unusual condition that occurred while your script was running. Instead of a function returning a special value like false or -1 to signal failure, it can throw an exception object, which immediately stops normal execution and hands control to code specifically written to handle that failure. This makes error handling explicit, structured, and impossible to silently ignore by accident. Exceptions are the backbone of robust PHP applications, from simple form validation to complex frameworks like Laravel and Symfony.

Overview: How Exceptions Work

Every exception in PHP is an object. When you write throw new Exception("message"), PHP creates an instance of the Exception class (or one of its subclasses) and hands it to the engine’s exception-handling machinery instead of letting execution continue normally. The Zend Engine then stops executing the current line and starts unwinding the call stack: it walks back up through every function and method call that is currently active, looking for a catch block whose type matches the thrown object. If it finds one, execution resumes inside that block with the exception object bound to the catch variable. If it reaches the top of the script without finding a match, PHP reports an uncaught exception fatal error and the script terminates.

Since PHP 7, all exceptions and fatal errors implement a common interface called Throwable. Under Throwable there are two main branches: Exception, which is the class hierarchy you throw yourself for expected, recoverable problems (invalid input, failed lookups, business-rule violations), and Error, which PHP throws internally for things that were previously fatal errors — calling a method on null, a type mismatch, dividing by zero, and so on. Exception and Error do not extend one another, so a catch (Exception $e) block will never catch a TypeError or a DivisionByZeroError. If you want to catch absolutely everything, you catch Throwable instead.

Every exception object carries a message, a numeric code, a reference to the file and line where it was thrown, a full stack trace, and optionally a “previous” exception used for chaining causes together. These are all accessible through methods defined on the base Exception class: getMessage(), getCode(), getFile(), getLine(), getTrace(), getTraceAsString(), and getPrevious().

Syntax

try {
    // code that might throw
} catch (SomeExceptionType $e) {
    // handle it
} catch (AnotherType | YetAnotherType $e) {
    // handle several types the same way
} finally {
    // always runs, whether an exception was thrown or not
}

throw new Exception(string $message = "", int $code = 0, ?Throwable $previous = null);
  • try — wraps the block of code you want to monitor for exceptions.
  • catch (Type $e) — runs if an exception matching Type (or one of its subclasses) is thrown inside the try block. You can list a union of types with | to handle several exception classes identically.
  • finally — an optional block that always executes after the try/catch, whether an exception was thrown, caught, or not thrown at all — even if the try or catch block returns early.
  • throw — creates and raises an exception object. Since PHP 8, throw is an expression, so it can be used inside arrow functions and the null coalescing operator.
  • The Exception constructor accepts a message, an optional application-defined code, and an optional previous exception used for chaining causes together.

Common Built-in Exception and Error Classes

Class Extends Typical Use
Exception Base class for all “normal” exceptions you throw
InvalidArgumentException LogicException A function received an argument of the wrong type or value
OutOfRangeException LogicException An illegal index was requested
RuntimeException Exception An error that can only be detected at run time
JsonException Exception Thrown by json_encode/json_decode when JSON_THROW_ON_ERROR is set
TypeError Error A value did not match a declared type hint
DivisionByZeroError ArithmeticError Integer division or modulo by zero

Examples

Example 1: Basic try/catch/finally

<?php
function divide(int $a, int $b): float {
    if ($b === 0) {
        throw new InvalidArgumentException("Cannot divide by zero");
    }
    return $a / $b;
}

try {
    echo divide(10, 2) . PHP_EOL;
    echo divide(5, 0) . PHP_EOL;
} catch (InvalidArgumentException $e) {
    echo "Error: " . $e->getMessage() . PHP_EOL;
} finally {
    echo "Division attempt finished." . PHP_EOL;
}

Output:

5
Error: Cannot divide by zero
Division attempt finished.

The first call succeeds and prints 5. The second call passes 0 as the divisor, so divide() throws an InvalidArgumentException before it ever reaches the division. Execution jumps straight to the matching catch block, which prints the error message — notice that the line echo divide(5, 0) never gets a chance to print anything itself, since the exception is thrown from inside the function call. Finally, the finally block runs regardless of whether an exception occurred.

Example 2: A Custom Exception Class

<?php
class InsufficientFundsException extends Exception
{
    public function __construct(
        private readonly float $requested,
        private readonly float $available
    ) {
        parent::__construct(
            sprintf("Requested %.2f but only %.2f available", $requested, $available)
        );
    }

    public function getShortfall(): float
    {
        return $this->requested - $this->available;
    }
}

class Account
{
    public function __construct(private float $balance) {}

    public function withdraw(float $amount): void
    {
        if ($amount > $this->balance) {
            throw new InsufficientFundsException($amount, $this->balance);
        }
        $this->balance -= $amount;
    }
}

$account = new Account(100.0);

try {
    $account->withdraw(150.0);
} catch (InsufficientFundsException $e) {
    echo $e->getMessage() . PHP_EOL;
    echo "Shortfall: " . $e->getShortfall() . PHP_EOL;
}

Output:

Requested 150.00 but only 100.00 available
Shortfall: 50

Instead of throwing a generic Exception, this example defines InsufficientFundsException, which extends Exception but adds its own constructor and an extra method, getShortfall(). It calls parent::__construct() to set the standard message, but also stores the requested and available amounts as its own readonly properties so the catching code can react programmatically, not just display text. This is the real power of custom exceptions: they carry structured data, not just a string.

Example 3: Exception Chaining Across Layers

<?php
class DatabaseException extends RuntimeException {}

function connectToDatabase(string $dsn): void
{
    throw new DatabaseException("Could not connect to $dsn");
}

function loadUserProfile(int $userId): array
{
    try {
        connectToDatabase("mysql:host=localhost;dbname=app");
        return ['id' => $userId];
    } catch (DatabaseException $e) {
        throw new RuntimeException("Failed to load profile for user $userId", 0, $e);
    }
}

try {
    loadUserProfile(42);
} catch (RuntimeException | LogicException $e) {
    echo "Top-level error: " . $e->getMessage() . PHP_EOL;
    $previous = $e->getPrevious();
    if ($previous !== null) {
        echo "Caused by: " . $previous->getMessage() . PHP_EOL;
    }
}

Output:

Top-level error: Failed to load profile for user 42
Caused by: Could not connect to mysql:host=localhost;dbname=app

A low-level DatabaseException is caught inside loadUserProfile() and re-thrown as a more general RuntimeException, but the original exception is passed as the third constructor argument. This is exception chaining: the outer exception keeps a reference to the inner one via getPrevious(), so nothing about the root cause is lost even though the caller only deals with a higher-level, more meaningful error type. The catch (RuntimeException | LogicException $e) line also shows a union catch, handling two unrelated exception hierarchies with one block.

Under the Hood: What Happens When You Throw

Understanding the exact sequence the engine follows removes a lot of confusion about finally and stack traces:

  1. Construction. new Exception(...) runs the constructor, which stores the message, code, and previous exception, and immediately captures the current file, line, and a full backtrace. This happens at construction time, not at throw time — so if you build an exception object in one place and throw it somewhere else, the reported file/line is where it was new‘d, not where throw executed.
  2. throw triggers unwinding. The engine stops the current instruction pointer and begins walking back up the call stack, frame by frame, looking for an enclosing try block whose catch clause matches the exception’s class (via instanceof).
  3. Local cleanup runs during unwinding. As each stack frame is torn down, any local objects going out of scope have their destructors (__destruct) invoked, in reverse order of creation.
  4. Match found. If a matching catch is found, execution resumes there with the exception object bound to the catch variable. Any finally block belonging to that try also runs, always, right after the try/catch body finishes — even if that body contains a return, break, or another throw.
  5. No match found. If the stack unwinds all the way to the top without a matching catch, PHP invokes the exception handler registered with set_exception_handler(), or if none is set, prints an “Uncaught Exception” fatal error with a full stack trace and terminates the process with a non-zero exit code.

Common Mistakes

Mistake 1: Swallowing Exceptions Silently

An empty catch block hides failures instead of handling them. The caller has no idea anything went wrong, and there is no log entry to help debug it later:

<?php
function saveOrder(array $order): bool
{
    try {
        processPayment($order);
        return true;
    } catch (Exception $e) {
    }
    return false;
}

This “works” in the sense that it doesn’t crash, but the failure vanishes into thin air. At minimum, log the exception; ideally, decide explicitly whether the caller should be told:

<?php
function saveOrder(array $order): bool
{
    try {
        processPayment($order);
        return true;
    } catch (Exception $e) {
        error_log("Payment failed for order: " . $e->getMessage());
        return false;
    }
}

Mistake 2: Catching Exception When You Meant Throwable

Many run-time failures in PHP — a TypeError from a bad argument, a DivisionByZeroError, calling a method on null — are instances of Error, not Exception. Since Error and Exception are siblings that both implement Throwable, a catch (Exception $e) will not catch them:

<?php
function calculateTotal(?array $items): float
{
    try {
        $total = 0.0;
        foreach ($items as $item) {
            $total += $item['price'];
        }
        return $total;
    } catch (Exception $e) {
        echo "Failed to calculate total." . PHP_EOL;
        return 0.0;
    }
}

If $items is null, foreach raises a TypeError, which this catch (Exception $e) block will not intercept — the error propagates uncaught. Catch Throwable when you genuinely want to handle both exceptions and engine errors:

<?php
function calculateTotal(?array $items): float
{
    try {
        $total = 0.0;
        foreach ($items as $item) {
            $total += $item['price'];
        }
        return $total;
    } catch (Throwable $e) {
        echo "Failed to calculate total: " . $e->getMessage() . PHP_EOL;
        return 0.0;
    }
}

Best Practices

  • Throw the most specific exception class available (or write your own) rather than a bare Exception — it lets callers catch precisely what they can handle.
  • Never use exceptions for routine control flow, like ending a loop; reserve them for genuinely exceptional, unexpected conditions.
  • Always preserve the original cause when re-throwing by passing it as the third constructor argument (new RuntimeException($msg, 0, $e)), so getPrevious() can reconstruct the full chain.
  • Catch as narrowly as possible, as close as possible to where you can actually do something useful — retry, log, or show a message — instead of catching broadly just to swallow the problem.
  • Use finally for cleanup that must always run, such as closing file handles or releasing locks, rather than duplicating that code in every catch branch.
  • Register a top-level set_exception_handler() in production so uncaught exceptions are logged cleanly instead of leaking stack traces to users.
  • Keep exception messages informative for developers, but never leak sensitive data such as passwords, tokens, or raw SQL into a message that might reach end users.

Practice Exercises

  • Write a function parseAge(string $input): int that throws an InvalidArgumentException if the input is not a positive integer, and returns the integer otherwise. Test it with a try/catch that prints a friendly error message.
  • Create a custom exception class StockUnavailableException that stores the requested quantity and the available quantity as properties, plus a method getShortfall(). Throw it from a reserveStock() function and catch it to print how many units are missing.
  • Write two functions, readConfigFile() and loadApplication(), where the second calls the first inside a try block and re-throws a new, more general exception while chaining the original as the “previous” exception. Catch the final exception and print both messages using getMessage() and getPrevious()->getMessage().

Summary

  • An exception is an object thrown with throw that interrupts normal flow and is caught by a matching catch block.
  • try/catch/finally control how exceptions are handled; finally always runs regardless of what happened in try or catch.
  • Exception and Error are separate branches under the Throwable interface — catch Throwable if you need to handle both.
  • Custom exception classes that extend Exception let you attach domain-specific data and behavior to your errors.
  • Exception chaining via the constructor’s third argument preserves the root cause when you translate a low-level exception into a higher-level one.
  • Never silently swallow exceptions — at minimum log them so failures remain visible.