0

Custom exception classes — domain exceptions

Intermediate5 min read·php-10-005
interviewsolid

Concept

Domain exceptions are custom exception classes that communicate meaningful business-level error conditions. Instead of throwing generic \RuntimeException('User not found'), you throw UserNotFoundException — an exception type that tells you exactly what went wrong at a business level.

Why custom exceptions:

  1. Catch specificity: Callers can catch UserNotFoundException and handle it differently from PaymentDeclinedException or DatabaseException.
  2. Documentation by code: The exception class name documents what can go wrong in a function without reading its body.
  3. Exception hierarchy per domain: Group related exceptions under a base: OrderExceptionOrderNotFoundExcep​tion, OrderAlreadyShippedException, InsufficientInventoryException. Catch OrderException to handle all order-related errors.
  4. Additional context: Custom exceptions can carry domain-specific data (e.g., ValidationException with an array of field errors, RateLimitException with a retryAfter timestamp).

Where to put them: Alongside the domain code that throws them. UserNotFoundException belongs in the User domain, not in a global Exceptions/ folder. Exception classes are part of the domain's public API.

Custom exception data:

php
class ValidationException extends \RuntimeException
{
    public function __construct(private array $errors)
    {
        parent::__construct("Validation failed: " . implode(', ', array_keys($errors)));
    }
    public function getErrors(): array { return $this->errors; }
}

Code Example

php
<?php
declare(strict_types=1);

// Domain exception hierarchy
class OrderException extends \RuntimeException {}
class OrderNotFoundException extends OrderException
{
    public function __construct(int $orderId)
    {
        parent::__construct("Order #$orderId was not found");
        $this->orderId = $orderId;
    }
    private int $orderId;
    public function getOrderId(): int { return $this->orderId; }
}
class OrderAlreadyShippedException extends OrderException
{
    public function __construct(int $orderId, \DateTimeImmutable $shippedAt)
    {
        parent::__construct("Order #$orderId was already shipped at " . $shippedAt->format('Y-m-d'));
    }
}

// Service that throws domain exceptions
class OrderService
{
    public function ship(int $orderId): void
    {
        $order = $this->repository->find($orderId)
            ?? throw new OrderNotFoundException($orderId);

        if ($order->isShipped()) {
            throw new OrderAlreadyShippedException($orderId, $order->getShippedAt());
        }

        $this->doShip($order);
    }
}

// Controller catching specific exceptions
try {
    $service->ship((int) $request->input('order_id'));
    return response()->json(['status' => 'shipped']);
} catch (OrderNotFoundException $e) {
    return response()->json(['error' => $e->getMessage()], 404);
} catch (OrderAlreadyShippedException $e) {
    return response()->json(['error' => $e->getMessage()], 422);
} catch (OrderException $e) {
    // Catch-all for any other order exception
    return response()->json(['error' => 'Order processing failed'], 500);
}

// Validation exception with structured error data
class ValidationException extends \RuntimeException
{
    public function __construct(private readonly array $errors)
    {
        parent::__construct("Validation failed");
    }
    public function getErrors(): array { return $this->errors; }
}

try {
    validate($data);
} catch (ValidationException $e) {
    $fieldErrors = $e->getErrors(); // ['email' => 'Invalid email', 'name' => 'Required']
    return response()->json(['errors' => $fieldErrors], 422);
}