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 example
<?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 example
<?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 example
<?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 example
<?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:

  • InvalidArgumentException for invalid function input
  • RuntimeException for runtime failures that do not need a specific type
  • LogicException for code paths that should be impossible
  • DomainException for 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 OrderCannotBeCancelled exception
  • named constructors for alreadyShipped() and alreadyCancelled()
  • an Order class with a cancel() method
  • catch logic that handles OrderCannotBeCancelled specifically

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 example
<?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.