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.
final 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
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.
<?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 cardA 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.
<?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 fundsNamed 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.
<?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