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:
- Catch specificity: Callers can catch
UserNotFoundExceptionand handle it differently fromPaymentDeclinedExceptionorDatabaseException. - Documentation by code: The exception class name documents what can go wrong in a function without reading its body.
- Exception hierarchy per domain: Group related exceptions under a base:
OrderException→OrderNotFoundException,OrderAlreadyShippedException,InsufficientInventoryException. CatchOrderExceptionto handle all order-related errors. - Additional context: Custom exceptions can carry domain-specific data (e.g.,
ValidationExceptionwith an array of field errors,RateLimitExceptionwith aretryAftertimestamp).
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);
}