Error Handling and Exceptions

try/catch/finally, catching multiple exception types, custom exception classes, and building an exception hierarchy.

Throwing and catching exceptions

An exception is an object representing something that went wrong, thrown with throw and caught with try/catch — PHP's mechanism for separating "the normal flow of a function" from "handling the cases where it can't complete normally":

PHP
<?php

function divide(float $a, float $b): float {
    if ($b === 0.0) {
        throw new DivisionByZeroError("Cannot divide by zero");
    }
    return $a / $b;
}

try {
    echo divide(10, 2);   // 5
    echo divide(10, 0);   // throws before this line's result is ever used
} catch (DivisionByZeroError $e) {
    echo "Error: " . $e->getMessage(); // Error: Cannot divide by zero
}

The moment throw executes, normal execution stops immediately — no code after the throw in divide() runs, and control jumps straight to the nearest matching catch block, unwinding any function calls in between. $e->getMessage() retrieves the human-readable message passed to the exception's constructor; every exception also exposes getCode(), getFile(), getLine(), and getTraceAsString() for a full stack trace.

finally

A finally block runs no matter what — whether the try block completed normally, an exception was caught, or an exception propagated past every catch block uncaught. It's the right place for cleanup that absolutely must happen regardless of outcome (closing a file handle, releasing a lock, logging that an operation was attempted):

PHP
<?php

function processPayment(float $amount): string {
    echo "Starting transaction\n";
    try {
        if ($amount <= 0) {
            throw new InvalidArgumentException("Amount must be positive");
        }
        return "Processed $amount";
    } finally {
        echo "Transaction cleanup ran\n"; // ALWAYS runs — success, failure, or uncaught exception
    }
}

try {
    echo processPayment(-10);
} catch (InvalidArgumentException $e) {
    echo "Caught: " . $e->getMessage() . "\n";
}
// Starting transaction
// Transaction cleanup ran
// Caught: Amount must be positive

Note that finally ran here even though the exception wasn't caught until outside processPayment entirely — finally fires during the stack unwinding itself, before the exception continues propagating further up.

Catching multiple exception types

A single catch can list several exception types separated by |, handling them identically; multiple catch blocks let different types be handled differently, and are checked in order from top to bottom — put more specific exception types before more general ones, since PHP uses the first match it finds:

PHP
<?php

function fetchUser(int $id): string {
    if ($id < 0) {
        throw new InvalidArgumentException("ID cannot be negative");
    }
    if ($id > 1000) {
        throw new OutOfRangeException("ID out of range");
    }
    if ($id === 0) {
        throw new RuntimeException("Database connection lost");
    }
    return "User #$id";
}

try {
    echo fetchUser(-5);
} catch (InvalidArgumentException | OutOfRangeException $e) { // one block, two related types
    echo "Bad input: " . $e->getMessage() . "\n";
} catch (RuntimeException $e) {
    echo "System error: " . $e->getMessage() . "\n";
}

InvalidArgumentException and OutOfRangeException both extend the built-in LogicException — catching LogicException alone (a common shortcut) would catch either without needing the | syntax at all, which is a preview of why exception hierarchies (below) matter in practice, not just as an abstract design exercise.

Custom exception classes

Extending PHP's built-in Exception (or one of its subclasses) lets you attach domain-specific data and behavior to an error, well beyond a plain string message:

PHP
<?php

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

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

class BankAccount {
    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 BankAccount(100.0);

try {
    $account->withdraw(150.0);
} catch (InsufficientFundsException $e) {
    echo $e->getMessage() . "\n";                 // Requested 150.00 but only 100.00 is available
    echo "Short by: " . $e->getShortfall() . "\n"; // Short by: 50
}

Calling parent::__construct($message) is what wires up getMessage(), the stack trace, and every other inherited Exception behavior — skipping it (a common mistake when first writing a custom exception) leaves getMessage() returning an empty string.

Exception hierarchies

PHP ships a built-in hierarchy of standard exceptions, and real applications typically extend it further with their own domain-specific base exceptions — the whole point being that calling code can catch at whatever level of specificity actually makes sense for it: a very specific type when it needs to react differently, or a shared parent type when any error in that whole family should be handled the same way.

Text
Throwable (interface — both Exception and Error implement this)
├── Exception
│   ├── InvalidArgumentException  (extends LogicException)
│   ├── OutOfRangeException       (extends LogicException)
│   ├── RuntimeException
│   │   └── OutOfBoundsException
│   └── App\Exceptions\PaymentException   <- your own application's base exception
│       ├── App\Exceptions\InsufficientFundsException
│       └── App\Exceptions\CardDeclinedException
└── Error                          (TypeError, DivisionByZeroError, etc. — see below)
PHP
<?php

namespace App\Exceptions;

use Exception;

class PaymentException extends Exception {}

class InsufficientFundsException extends PaymentException {}
class CardDeclinedException extends PaymentException {}
PHP
<?php

use App\Exceptions\PaymentException;

function chargeCard(): void {
    // ... something goes wrong somewhere inside payment processing ...
}

try {
    chargeCard();
} catch (PaymentException $e) {   // catches EITHER subclass — and any future one added later
    echo "Payment failed: " . $e->getMessage();
}

This is the real payoff of building an exception hierarchy: code that only cares "did any payment-related thing go wrong" can catch the shared PaymentException base type once, while code that needs to react differently to a declined card versus insufficient funds can still catch each specific subclass separately — both without either side needing to know about the other's concerns.

Exception vs. Error

Since PHP 7, Throwable is the top-level interface both Exception and Error implement. Exception (and its subclasses) represent conditions your own application code is expected to throw and handle deliberately. Error (and subclasses like TypeError, DivisionByZeroError, ArgumentCountError) represent internal engine-level problems — a genuine programming mistake, like calling a method with the wrong argument types — that are usually not meant to be routinely caught and recovered from the way a domain exception is, though catch (Throwable $e) can catch either when a truly universal safety net is needed (a top-level request handler logging any uncaught failure, for instance).

Common mistakes

  • Forgetting to call parent::__construct($message) in a custom exception's constructor, leaving getMessage() and the stack trace empty or wrong.
  • Catching a broad type like Exception (or worse, Throwable) everywhere out of caution, which silently swallows genuinely unexpected bugs alongside the specific error you meant to handle.
  • Ordering catch blocks from general to specific — since PHP checks them top to bottom and uses the first match, a general parent type listed first will swallow everything, and a more specific catch block listed after it becomes unreachable dead code.
  • Using exceptions for routine, expected control flow (like "this key might not exist in an array") instead of for genuinely exceptional conditions — a plain null check or ?? is simpler and clearer for the common, expected case.

Interview questions

Q: What does a finally block guarantee, and what's it typically used for? It guarantees the block runs regardless of how the try block ends — normal completion, an exception caught by a catch, or an exception that propagates past every catch uncaught — even executing during the stack unwinding before an uncaught exception continues outward. It's the right place for cleanup that has to happen no matter what the outcome was, like closing a resource or logging that an operation was attempted, rather than duplicating that cleanup code in both the success path and every catch block.

Q: Why would an application build its own exception hierarchy (a base PaymentException with several subclasses) instead of just throwing a plain Exception everywhere? A hierarchy lets calling code catch at exactly the level of specificity it needs: a broad catch (PaymentException $e) handles anything payment-related uniformly (useful for a generic "something failed, show an error page" handler), while a narrower catch (CardDeclinedException $e) reacts specifically to that one case (prompting for a different card, say) without affecting how other payment failures are handled. Throwing only plain Exception everywhere would force every catch site to either catch everything indiscriminately or inspect the message string to figure out what actually went wrong, which is fragile and not type-checked.