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
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
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
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
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.
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 leavesgetMessage()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
catchblocks from most specific to least specific -- PHP rejects the reverse order at compile time.