PHPCustom Exception Classes

Custom Exception Classes

PHP's built-in exceptions — \\RuntimeException, \\InvalidArgumentException, \\LogicException — are deliberately generic. They're fine for quick scripts, but a real application benefits from exceptions that speak its own vocabulary. An e-commerce checkout doesn't just fail with a RuntimeException; it fails because of InsufficientFundsException, OutOfStockException, or InvalidCouponException. Naming the failure precisely makes both the calling code and the log output far easier to reason about.

Extending \Exception

A custom exception is just a class that extends \\Exception (or one of its subclasses). It inherits getMessage(), getCode(), getFile(), getLine(), and getTrace() for free, and can be thrown and caught exactly like a built-in exception.

A minimal custom exception

PHP
<?php
class InsufficientFundsException extends \Exception
{
}

function withdraw(float $balance, float $amount): float
{
    if ($amount > $balance) {
        throw new InsufficientFundsException(
            "Cannot withdraw {$amount}, balance is only {$balance}",
        );
    }

    return $balance - $amount;
}

try {
    withdraw(50.0, 75.0);
} catch (InsufficientFundsException $e) {
    echo 'Blocked: ', $e->getMessage(), "\n";
}
Blocked: Cannot withdraw 75, balance is only 50
Adding custom properties and a constructor

The real value of a custom exception class shows up once it carries more than just a message string. Overriding the constructor lets you attach structured data — the account ID, the amount requested, the amount available — that calling code can read back with plain getter methods instead of parsing it out of a formatted message.

A custom exception with structured context

PHP
<?php
class InsufficientFundsException extends \Exception
{
    public function __construct(
        private readonly float $requested,
        private readonly float $available,
    ) {
        parent::__construct(
            "Cannot withdraw {$requested}, only {$available} available",
        );
    }

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

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

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

function withdraw(float $balance, float $amount): float
{
    if ($amount > $balance) {
        throw new InsufficientFundsException($amount, $balance);
    }

    return $balance - $amount;
}

try {
    withdraw(50.0, 75.0);
} catch (InsufficientFundsException $e) {
    printf(
        "Short by %.2f (wanted %.2f, had %.2f)\n",
        $e->getShortfall(),
        $e->getRequested(),
        $e->getAvailable(),
    );
}
Short by 25.00 (wanted 75.00, had 50.00)

Calling parent::__construct($message) is essential here — skipping it means getMessage() returns an empty string, since \\Exception's own constructor is what actually stores the message internally.

Building a small exception hierarchy

Rather than extending \\Exception directly everywhere, a common and useful pattern is to introduce one base exception per domain area, then extend that for specific cases. This lets calling code choose how precise it wants to be: catch the narrow subtype for special handling, or catch the shared base type to handle "anything wrong with billing" uniformly.

A billing exception hierarchy

PHP
<?php
abstract class BillingException extends \DomainException
{
}

class InsufficientFundsException extends BillingException
{
    public function __construct(
        private readonly float $requested,
        private readonly float $available,
    ) {
        parent::__construct(
            "Cannot withdraw {$requested}, only {$available} available",
        );
    }

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

class CardDeclinedException extends BillingException
{
    public function __construct(private readonly string $declineReason)
    {
        parent::__construct("Card declined: {$declineReason}");
    }
}

BillingException extends \\DomainException was a deliberate choice: \\DomainException (a built-in SPL exception) signals "the value is semantically wrong for this business rule," which fits both insufficient funds and a declined card better than the more generic \\Exception would.

Catching by specific subtype

Handling each billing failure differently, or all of them generically

PHP
<?php
function charge(float $balance, float $amount, bool $cardDeclined): float
{
    if ($cardDeclined) {
        throw new CardDeclinedException('expired card');
    }

    if ($amount > $balance) {
        throw new InsufficientFundsException($amount, $balance);
    }

    return $balance - $amount;
}

function processPayment(float $balance, float $amount, bool $cardDeclined): void
{
    try {
        $newBalance = charge($balance, $amount, $cardDeclined);
        echo "Charged successfully, new balance {$newBalance}\n";
    } catch (InsufficientFundsException $e) {
        // Specific: offer the exact shortfall amount.
        printf("Add %.2f more to complete this purchase\n", $e->getShortfall());
    } catch (BillingException $e) {
        // Generic: any other billing problem, handled the same way.
        echo 'Payment failed: ', $e->getMessage(), "\n";
    }
}

processPayment(50.0, 75.0, cardDeclined: false);
processPayment(50.0, 75.0, cardDeclined: true);
Add 25.00 more to complete this purchase
Payment failed: Card declined: expired card

Order matters in this catch chain: PHP tries each catch block top to bottom and uses the first one whose type matches, so the more specific InsufficientFundsException block has to come before the broader BillingException block. Reversing the order would mean the specific block never runs, since BillingException would already have matched first.

A subtype-first catch order is not optional
If a broad `catch (\\Exception $e)` (or any ancestor type) appears before a `catch` for one of its own subclasses, PHP raises a fatal compile-time error: "Unreachable catch block." This is one case where PHP itself protects you from the mistake, unlike languages that would silently let the specific block go dead.
  • Extend \Exception (or a built-in SPL exception like \DomainException, \RuntimeException) to create a named, catchable failure type.

  • Always call parent::__construct($message, ...) -- skipping it leaves getMessage() empty.

  • Custom properties plus getters let a caught exception carry structured context, not just a formatted string.

  • Group related exceptions under one abstract base class so callers can catch broadly or narrowly as needed.

  • Order catch blocks from most specific to least specific -- PHP rejects the reverse order at compile time.

Tip
Name exception classes after what went wrong, not where the code happens to be: `InsufficientFundsException` reads clearly in a log line months later, while a generic `PaymentException` forces someone to go read the message text to understand the actual failure.