advanced php language
Custom Exceptions
Custom exceptions give a failure a meaningful type. They are useful when code needs to catch or report a specific kind of failure, not just any RuntimeException or InvalidArgumentException.
Do not create a custom exception for every throw. Built-in exceptions are often enough. Create a custom exception when the name improves the code, when callers need to catch that failure separately, or when the exception should carry useful context.
A Basic Custom Exception
A custom exception usually extends RuntimeException, LogicException, or another suitable built-in exception.
<?php
declare(strict_types=1);
final class PaymentDeclined extends RuntimeException
{
}
function chargeCard(bool $approved): void
{
if (!$approved) {
throw new PaymentDeclined('The payment was declined.');
}
}
try {
chargeCard(false);
} catch (PaymentDeclined $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// The payment was declined.
The catch block now communicates exactly which failure it handles.
Named Constructors For Clear Cases
Named constructors make exception creation more consistent and expressive.
<?php
declare(strict_types=1);
final class OrderCannotBeCancelled extends RuntimeException
{
public static function alreadyShipped(int $orderId): self
{
return new self('Order ' . $orderId . ' has already shipped.');
}
public static function alreadyCancelled(int $orderId): self
{
return new self('Order ' . $orderId . ' is already cancelled.');
}
}
function cancelOrder(int $orderId, string $status): void
{
if ($status === 'shipped') {
throw OrderCannotBeCancelled::alreadyShipped($orderId);
}
if ($status === 'cancelled') {
throw OrderCannotBeCancelled::alreadyCancelled($orderId);
}
}
try {
cancelOrder(100, 'shipped');
} catch (OrderCannotBeCancelled $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// Order 100 has already shipped.
Named constructors are helpful when one exception type has several common reasons.
Exceptions Can Carry Context
Sometimes code that catches the exception needs structured context, not only a message.
<?php
declare(strict_types=1);
final class InventoryUnavailable extends RuntimeException
{
public function __construct(
public readonly string $sku,
public readonly int $requested,
public readonly int $available,
) {
parent::__construct(
'Only ' . $available . ' units are available for ' . $sku . '.'
);
}
}
function reserveStock(string $sku, int $requested, int $available): void
{
if ($requested > $available) {
throw new InventoryUnavailable($sku, $requested, $available);
}
}
try {
reserveStock('BOOK', 5, 2);
} catch (InventoryUnavailable $exception) {
echo $exception->sku . ': ' . $exception->getMessage() . PHP_EOL;
}
// Prints:
// BOOK: Only 2 units are available for BOOK.
Structured context is useful for logs, API responses, metrics, and tests.
Domain Exceptions
In domain code, custom exceptions often represent business rule failures.
<?php
declare(strict_types=1);
final class SubscriptionAlreadyCancelled extends DomainException
{
public static function forSubscription(int $subscriptionId): self
{
return new self('Subscription ' . $subscriptionId . ' is already cancelled.');
}
}
final class Subscription
{
public function __construct(
private int $id,
private bool $cancelled = false,
) {
}
public function cancel(): void
{
if ($this->cancelled) {
throw SubscriptionAlreadyCancelled::forSubscription($this->id);
}
$this->cancelled = true;
}
}
$subscription = new Subscription(50, cancelled: true);
try {
$subscription->cancel();
} catch (SubscriptionAlreadyCancelled $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// Subscription 50 is already cancelled.
The exception name now reads like a business rule.
When Built-In Exceptions Are Enough
Use built-in exceptions when the failure is generic:
InvalidArgumentExceptionfor invalid function inputRuntimeExceptionfor runtime failures that do not need a specific typeLogicExceptionfor code paths that should be impossibleDomainExceptionfor a value outside the expected domain
If no caller will catch the exception specifically and the name does not add meaning, a built-in exception is usually fine.
Common Mistakes
Avoid custom exception classes with vague names such as AppException or ServiceException. They do not communicate more than RuntimeException.
Avoid catching a custom exception and returning success. If a business rule failed, make that visible to the caller.
Avoid putting too much behaviour in exception classes. They should describe failure, not fix it.
What You Should Be Able To Do
After this lesson, you should be able to create a custom exception, add named constructors, carry structured context, and decide when a built-in exception is clearer.
For junior work, this matters because good custom exceptions make failure paths readable in services, domain objects, queue jobs, and API boundaries.
Practice
Practice: Create A Business Rule Exception
Create a small PHP example with a custom exception for an order cancellation rule.
Task
Build:
- an
OrderCannotBeCancelledexception - named constructors for
alreadyShipped()andalreadyCancelled() - an
Orderclass with acancel()method - catch logic that handles
OrderCannotBeCancelledspecifically
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Run cases for:
- cancelling a shipped order
- cancelling an already cancelled order
- cancelling a paid order successfully
Afterward, explain why this custom exception is clearer than throwing a generic RuntimeException.
Show solution
This solution gives the cancellation rule a named exception type and named constructors for the common failure reasons.
<?php
declare(strict_types=1);
final class OrderCannotBeCancelled extends RuntimeException
{
public static function alreadyShipped(int $orderId): self
{
return new self('Order ' . $orderId . ' has already shipped.');
}
public static function alreadyCancelled(int $orderId): self
{
return new self('Order ' . $orderId . ' is already cancelled.');
}
}
final class Order
{
public function __construct(
private int $id,
private string $status,
) {
}
public function cancel(): string
{
if ($this->status === 'shipped') {
throw OrderCannotBeCancelled::alreadyShipped($this->id);
}
if ($this->status === 'cancelled') {
throw OrderCannotBeCancelled::alreadyCancelled($this->id);
}
$this->status = 'cancelled';
return 'Order cancelled.';
}
}
foreach ([
new Order(100, 'shipped'),
new Order(101, 'cancelled'),
new Order(102, 'paid'),
] as $order) {
try {
echo $order->cancel() . PHP_EOL;
} catch (OrderCannotBeCancelled $exception) {
echo $exception->getMessage() . PHP_EOL;
}
}
// Prints:
// Order 100 has already shipped.
// Order 101 is already cancelled.
// Order cancelled.
The custom exception is clearer than a generic RuntimeException because the type states the business failure being handled.