Your Own Exception Classes

Write an exception class for each failure a caller can act on, carry the data on the object, and never parse the message text.

Syntaxfinal class PaymentDeclined extends RuntimeException { /* typed properties and accessors */ }

The built-in classes run out as soon as the caller needs to act on the particular failure. catch (RuntimeException $e) cannot tell a declined card from a timed-out gateway, and one deserves a new card form while the other deserves a retry.

One class per decision

Write a class when a caller would branch on it. Extend the closest built-in so broad handlers keep working: final class PaymentDeclined extends RuntimeException is caught by catch (PaymentDeclined) and also by an outer catch (RuntimeException). An empty class body is a perfectly good class.

Put the data on the object

Give it typed properties for whatever the handler needs: the field errors, a charge id, an enum reason. Readonly promoted constructor properties suit this, and the constructor can build the human message from them. The message is for the person reading the log.

A message is not an API

Code like str_contains($e->getMessage(), 'declined') breaks the day someone improves the wording. It breaks silently, in the error path, which is the least exercised part of the program. Branch on the class or on a property, never on the text.

A marker interface for a module

Declare interface BillingFailure extends Throwable and have each billing exception implement it. One handler can then catch the module's failures without listing them, and adding a new one does not mean editing the handler.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return every field error from one validation call instead of stopping at the first.
  • Let a controller show a new card form for a decline and a retry for a gateway timeout.
  • Catch everything one module can throw with a single marker interface.
  • Keep the wording of a failure in one class rather than scattered across call sites.

More examples

An enum reason the caller can match on

The reason is an enum, so match() over it is exhaustive: add a third case and any match that forgot it throws UnhandledMatchError instead of quietly falling through. The properties are public and readonly, which is why no getters are needed, and the constructor still calls parent::__construct() so the message and $previous work. Reach for this shape when one failure has a handful of known variants.

Example · php
<?php

declare(strict_types=1);

enum DeclineReason: string
{
    case InsufficientFunds = 'insufficient_funds';
    case ExpiredCard = 'expired_card';
}

final class PaymentDeclined extends RuntimeException
{
    public function __construct(
        public readonly DeclineReason $reason,
        public readonly string $chargeId,
        ?Throwable $previous = null,
    ) {
        parent::__construct("payment {$chargeId} declined: {$reason->value}", 0, $previous);
    }
}

try {
    throw new PaymentDeclined(DeclineReason::ExpiredCard, 'ch_42');
} catch (PaymentDeclined $e) {
    echo match ($e->reason) {
        DeclineReason::ExpiredCard => "ask for a new card\n",
        DeclineReason::InsufficientFunds => "offer a smaller plan\n",
    };
}

// ask for a new card

A marker interface for a whole module

An interface may extend Throwable and be used in a catch, so one block can cover every failure a module declares. The class still extends RuntimeException, because a class cannot implement Throwable directly. Note that the InvalidArgumentException is deliberately not a BillingFailure: it is a bug in the caller, not a billing outcome, so it falls to a different block.

Example · php
<?php

declare(strict_types=1);

interface BillingFailure extends Throwable
{
}

final class CardDeclined extends RuntimeException implements BillingFailure
{
}

final class GatewayUnreachable extends RuntimeException implements BillingFailure
{
}

function charge(array $input): string
{
    $card = $input['card'] ?? throw new InvalidArgumentException('card is missing');

    throw new CardDeclined('insufficient funds');
}

try {
    charge(['card' => 'tok_1']);
} catch (BillingFailure $e) {
    echo 'billing problem: ', $e->getMessage(), "\n";
} catch (InvalidArgumentException $e) {
    echo 'bad call: ', $e->getMessage(), "\n";
}

// billing problem: insufficient funds

Named constructors instead of a message argument

Static factory methods keep every message for a failure in the class that represents it, so the log phrasing stays consistent and the call site reads as a sentence. They also make the right arguments obvious: rowRejected() cannot be called without a cause, which is how the previous exception stops being optional in practice.

Example · php
<?php

declare(strict_types=1);

final class ImportFailed extends RuntimeException
{
    public static function fileMissing(string $path): self
    {
        return new self("import file not found: {$path}");
    }

    public static function rowRejected(int $line, Throwable $cause): self
    {
        return new self("row {$line} could not be imported", 0, $cause);
    }
}

// At the call sites the wording is decided once, in one file:
throw ImportFailed::fileMissing('/srv/imports/orders.csv');

Discussion

  • Be the first to comment on this lesson.