PHP Custom Exceptions

When something goes wrong in a PHP script — a bank account can’t cover a withdrawal, a config file is missing, an API request references a user that doesn’t exist — a generic error message isn’t enough. A custom exception is a class you write that extends PHP’s built-in Exception (or one of its subclasses) so each kind of failure in your application gets its own precisely named type. Instead of catching a vague Exception and guessing what happened from a string, you catch InsufficientFundsException or NotFoundException directly, with structured data already attached to it.

Overview: How Custom Exceptions Work

PHP models errors as objects. Every exception in PHP implements the built-in Throwable interface, which is split into two branches: Exception (for expected, recoverable runtime conditions your own code raises) and Error (for engine-level problems like calling an undefined method or dividing by zero, which PHP itself throws). Only objects that implement Throwable can be used with the throw keyword or caught in a catch block — if you try to throw a plain class instance, PHP raises a TypeError at the moment of the throw.

A custom exception is simply a class declared with extends Exception (or extends Throwable-implementing class such as InvalidArgumentException, RuntimeException, or LogicException). Because it inherits from Exception, it automatically gets a constructor accepting a message, a numeric code, and a "previous" exception, plus methods like getMessage(), getCode(), getFile(), getLine(), getTrace(), getTraceAsString(), and getPrevious(). You rarely need to reimplement any of this — you extend the class purely to give it a distinct name, and optionally add your own properties (like an order ID, an HTTP status code, or a list of validation errors) and methods to read them back out.

When throw executes, PHP immediately stops normal execution and starts unwinding the call stack, looking for the nearest enclosing try block whose catch clause matches the exception’s class (or one of its parent classes/interfaces). If no match is found in the current function, PHP pops back to the caller and checks there, and so on, all the way up to the global scope. If nothing ever catches it, PHP triggers a fatal error and, if set, calls the handler registered with set_exception_handler(). This is why exception type matters so much: catch blocks match by class hierarchy, so a well-designed set of custom exceptions lets calling code catch exactly the failures it knows how to handle and let everything else propagate.

Syntax

class MyException extends Exception
{
    // optional: extra properties
    public function __construct(string $message, private readonly mixed $context = null)
    {
        parent::__construct($message); // always forward to the parent!
    }

    public function getContext(): mixed
    {
        return $this->context;
    }
}

throw new MyException("Something specific went wrong", $someData);
  • extends Exception — makes the class throwable and gives it getMessage(), getCode(), getPrevious(), etc. for free.
  • __construct() — optional override; only needed when you want extra parameters or defaults. It must call parent::__construct() to store the message/code/previous exception.
  • parent::__construct(string $message = "", int $code = 0, ?Throwable $previous = null) — the base Exception constructor signature you are forwarding to.
  • Extra properties — any data specific to the failure (an ID, a list of errors, a limit that was exceeded) that callers can retrieve through your own getter methods.
  • throw new MyException(...) — creates the object and immediately unwinds the stack looking for a matching catch.

Examples

Example 1: A basic custom exception

<?php

class InvalidAgeException extends Exception
{
}

function setAge(int $age): void
{
    if ($age < 0 || $age > 150) {
        throw new InvalidAgeException("Age must be between 0 and 150, got {$age}.");
    }
    echo "Age set to {$age}.\n";
}

try {
    setAge(200);
} catch (InvalidAgeException $e) {
    echo "Error: " . $e->getMessage() . "\n";
    echo "Code: " . $e->getCode() . "\n";
}

Output:

Error: Age must be between 0 and 150, got 200.
Code: 0

Here InvalidAgeException adds nothing beyond a name — and that alone is already useful, because the catch (InvalidAgeException $e) block only matches this specific failure, not any other Exception that might be thrown elsewhere in setAge(). The message and code come straight from the inherited Exception constructor since we never overrode it.

Example 2: Attaching extra context

<?php

class InsufficientFundsException extends Exception
{
    public function __construct(
        string $message,
        private readonly float $balance,
        private readonly float $requested,
    ) {
        parent::__construct($message);
    }

    public function getBalance(): float
    {
        return $this->balance;
    }

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

class BankAccount
{
    private float $balance;

    public function __construct(float $startingBalance)
    {
        $this->balance = $startingBalance;
    }

    public function withdraw(float $amount): void
    {
        if ($amount > $this->balance) {
            throw new InsufficientFundsException(
                "Cannot withdraw \${$amount}, balance is only \${$this->balance}.",
                $this->balance,
                $amount,
            );
        }
        $this->balance -= $amount;
    }
}

$account = new BankAccount(100.00);

try {
    $account->withdraw(250.00);
} catch (InsufficientFundsException $e) {
    echo $e->getMessage() . "\n";
    echo "You are short by \$" . number_format($e->getShortfall(), 2) . "\n";
}

Output:

Cannot withdraw $250, balance is only $100.
You are short by $150.00

This is where custom exceptions really pay off: $balance and $requested are captured as constructor-promoted, readonly properties, and exposed through getBalance() and getShortfall(). The catching code doesn’t have to parse the message string to figure out how much money was missing — it just calls a method and gets a typed value back.

Example 3: An exception hierarchy with an abstract base

<?php

abstract class ApiException extends Exception
{
    abstract public function getHttpStatusCode(): int;
}

class NotFoundException extends ApiException
{
    public function getHttpStatusCode(): int
    {
        return 404;
    }
}

class ValidationException extends ApiException
{
    public function __construct(string $message, private readonly array $errors = [])
    {
        parent::__construct($message);
    }

    public function getHttpStatusCode(): int
    {
        return 422;
    }

    public function getErrors(): array
    {
        return $this->errors;
    }
}

function findUser(int $id): array
{
    $users = [1 => ['name' => 'Ana'], 2 => ['name' => 'Beto']];

    if (!isset($users[$id])) {
        throw new NotFoundException("User #{$id} was not found.");
    }

    return $users[$id];
}

function processRequest(int $id): void
{
    try {
        $user = findUser($id);
        echo "Found user: {$user['name']}\n";
    } catch (ApiException $e) {
        echo "API error ({$e->getHttpStatusCode()}): {$e->getMessage()}\n";
    } finally {
        echo "Request finished.\n";
    }
}

processRequest(1);
processRequest(99);

Output:

Found user: Ana
Request finished.
API error (404): User #99 was not found.
Request finished.

Both NotFoundException and ValidationException extend the abstract ApiException, which forces every subclass to implement getHttpStatusCode(). The single catch (ApiException $e) block in processRequest() catches any exception in that family, and can still call getHttpStatusCode() on it because the abstract base guarantees the method exists. The finally block runs every time, whether an exception was thrown or not — useful for cleanup like closing a database connection.

Under the Hood: What Happens Step by Step

  • 1. Class resolution. When PHP compiles class InsufficientFundsException extends Exception, it links your class into the existing hierarchy. It now inherits every property and method of Exception (like the private $message and $code properties) unless you override them.
  • 2. Object creation. new InsufficientFundsException(...) allocates the object and runs your constructor. If you defined your own __construct(), PHP runs only that one — the parent constructor does not run automatically. That’s why you must explicitly call parent::__construct($message).
  • 3. The throw statement. PHP checks that the object implements Throwable (guaranteed here since it extends Exception), then begins unwinding the call stack, exiting each function/block on the way, running any finally blocks it passes through.
  • 4. Matching a catch. PHP compares the exception’s actual class against each catch type in order, using instanceof semantics — so a catch (ApiException $e) matches a thrown NotFoundException because of the extends relationship.
  • 5. Handling and continuing. Once caught, execution resumes normally after the try/catch/finally block — the stack does not continue unwinding past the block that caught it.

Common Mistakes

Mistake 1: Forgetting to call parent::__construct()

If you override the constructor to accept extra parameters but never forward the message to the parent, getMessage() silently returns an empty string.

<?php

class OrderException extends Exception
{
    public function __construct(string $message, private readonly int $orderId)
    {
        // Oops: parent::__construct($message) was never called
    }

    public function getOrderId(): int
    {
        return $this->orderId;
    }
}

try {
    throw new OrderException("Order could not be processed.", 42);
} catch (OrderException $e) {
    echo "Message: '" . $e->getMessage() . "'\n";
    echo "Order ID: " . $e->getOrderId() . "\n";
}

Output:

Message: ''
Order ID: 42

The order ID still works because it’s your own property, but the message is lost because the parent constructor, which stores it, never ran. Fix it by always forwarding to parent::__construct():

<?php

class OrderException extends Exception
{
    public function __construct(string $message, private readonly int $orderId)
    {
        parent::__construct($message);
    }

    public function getOrderId(): int
    {
        return $this->orderId;
    }
}

try {
    throw new OrderException("Order could not be processed.", 42);
} catch (OrderException $e) {
    echo "Message: '" . $e->getMessage() . "'\n";
    echo "Order ID: " . $e->getOrderId() . "\n";
}

Output:

Message: 'Order could not be processed.'
Order ID: 42

Mistake 2: Losing the original exception when wrapping it

It’s common to catch a low-level exception and rethrow a higher-level, more meaningful one. If you forget to pass the original as the "previous" exception, you lose the real root cause — which makes debugging production issues much harder.

<?php

class RepositoryException extends Exception
{
}

function loadConfig(string $path): array
{
    if (!file_exists($path)) {
        throw new RuntimeException("Config file missing: {$path}");
    }
    return [];
}

function bootstrap(): void
{
    try {
        loadConfig('/etc/app/config.php');
    } catch (RuntimeException $e) {
        // Oops: the original exception is discarded
        throw new RepositoryException("Failed to bootstrap the application.");
    }
}

try {
    bootstrap();
} catch (RepositoryException $e) {
    echo "Error: " . $e->getMessage() . "\n";
    echo "Previous: " . ($e->getPrevious()?->getMessage() ?? 'none') . "\n";
}

Output:

Error: Failed to bootstrap the application.
Previous: none

Pass the caught exception as the third constructor argument to preserve the chain:

<?php

class RepositoryException extends Exception
{
}

function loadConfig(string $path): array
{
    if (!file_exists($path)) {
        throw new RuntimeException("Config file missing: {$path}");
    }
    return [];
}

function bootstrap(): void
{
    try {
        loadConfig('/etc/app/config.php');
    } catch (RuntimeException $e) {
        throw new RepositoryException("Failed to bootstrap the application.", 0, $e);
    }
}

try {
    bootstrap();
} catch (RepositoryException $e) {
    echo "Error: " . $e->getMessage() . "\n";
    echo "Previous: " . ($e->getPrevious()?->getMessage() ?? 'none') . "\n";
}

Output:

Error: Failed to bootstrap the application.
Previous: Config file missing: /etc/app/config.php

The nullsafe operator ?-> is used here so that getPrevious() can safely be chained even when it returns null.

Best Practices

  • Name exception classes after the condition, not the mechanism — InsufficientFundsException, not BankError.
  • Always call parent::__construct() when you override the constructor, even if you add other parameters.
  • Extend the most specific built-in base that fits (InvalidArgumentException, OutOfRangeException, RuntimeException) instead of raw Exception when your case matches one of SPL’s predefined exception types.
  • Group related exceptions under a common abstract or interface base (like ApiException) so callers can catch a whole family with one catch clause when they don’t need to distinguish further.
  • Preserve the original exception with the $previous parameter whenever you wrap or translate an exception, so stack traces and root causes are never lost.
  • Keep exception classes free of side effects — don’t log, send emails, or touch the database inside the constructor; do that in the code that catches the exception.
  • Only catch exceptions you can actually do something about; let everything else propagate up to a top-level handler or set_exception_handler().
  • Attach machine-readable data (IDs, codes, arrays of errors) as typed properties with getters, not by cramming everything into the message string.

Practice Exercises

  • Exercise 1: Create a PasswordTooWeakException class extending Exception that stores the minimum required length and the length that was actually provided. Write a validatePassword(string $password, int $minLength) function that throws it when the password is too short, and a try/catch block that prints a message like "Password must be at least 8 characters, got 5."
  • Exercise 2: Build a small exception hierarchy for a file-upload feature: an abstract UploadException, and two concrete subclasses FileTooLargeException and UnsupportedFileTypeException, each exposing its own relevant getter (max size, or a list of allowed types). Write one function that throws each kind conditionally, and one catch (UploadException $e) block that handles both.
  • Exercise 3: Simulate a two-layer application: a fetchFromDatabase() function that throws a plain RuntimeException, and a getUserProfile() function that catches it and rethrows a custom ProfileLoadException, correctly passing the original exception as the previous one. Print both the outer message and the previous exception’s message to confirm the chain is intact.

Summary

  • A custom exception is a class that extends Exception (or another Throwable-implementing class), giving your code a precise, named type for each kind of failure.
  • Only objects implementing Throwable can be thrown or caught; PHP raises a TypeError if you try to throw anything else.
  • If you override the constructor, you must call parent::__construct() yourself — it does not run automatically.
  • Extra properties and getter methods let callers retrieve structured context instead of parsing an error string.
  • catch blocks match by class hierarchy, so grouping related exceptions under a shared abstract base or interface lets calling code catch broadly or narrowly as needed.
  • Always pass the original exception as the $previous argument when wrapping one exception in another, to preserve the full chain of causes for debugging.
  • finally blocks always run, whether an exception was thrown, caught, or not thrown at all — use them for cleanup.