ahmed-bhs/hexagonal-maker-bundle
layout: default
Exception (PHP)
├── DomainException (Custom base)
│ ├── ValidationException
│ │ ├── InvalidEmailException
│ │ ├── PasswordTooShortException
│ │ └── InvalidQuantityException
│ ├── BusinessRuleException
│ │ ├── OrderAlreadyShippedException
│ │ ├── InsufficientStockException
│ │ └── CannotCancelShippedOrderException
│ └── NotFoundException
│ ├── UserNotFoundException
│ ├── OrderNotFoundException
│ └── ProductNotFoundException
└── InfrastructureException (Custom base)
├── PersistenceException
│ ├── DatabaseConnectionException
│ └── UniqueConstraintViolationException
├── ExternalServiceException
│ ├── PaymentGatewayException
│ └── EmailSendingException
└── CacheException
namespace App\Shared\Domain\Exception;
// Base for all domain exceptions
abstract class DomainException extends \Exception
{
public function __construct(
string $message = '',
int $code = 0,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
}
// Domain validation failures
abstract class ValidationException extends DomainException {}
// Business rule violations
abstract class BusinessRuleException extends DomainException {}
// Entity not found
abstract class NotFoundException extends DomainException
{
public function __construct(string $entityName, string $identifier)
{
parent::__construct("$entityName with identifier '$identifier' not found");
}
}
namespace App\Shared\Infrastructure\Exception;
// Base for all infrastructure exceptions
abstract class InfrastructureException extends \Exception {}
// Database/persistence failures
abstract class PersistenceException extends InfrastructureException {}
// External service failures
abstract class ExternalServiceException extends InfrastructureException {}
Purpose: Represent business rule violations or domain-specific errors.
Characteristics:
namespace App\User\Domain\Exception;
final class InvalidEmailException extends ValidationException
{
public function __construct(string $email)
{
parent::__construct("Email '$email' is not valid");
}
}
final class PasswordTooShortException extends ValidationException
{
public function __construct()
{
parent::__construct("Password must be at least 8 characters");
}
}
namespace App\Order\Domain\Exception;
final class CannotShipCancelledOrderException extends BusinessRuleException
{
public function __construct()
{
parent::__construct("Cannot ship an order that has been cancelled");
}
}
final class OrderAlreadyShippedException extends BusinessRuleException
{
public function __construct(OrderId $orderId)
{
parent::__construct("Order {$orderId} has already been shipped");
}
}
final class InsufficientStockException extends BusinessRuleException
{
public function __construct(ProductId $productId, int $requested, int $available)
{
parent::__construct(
"Insufficient stock for product {$productId}: requested $requested, available $available"
);
}
}
namespace App\User\Domain\Exception;
final class UserNotFoundException extends NotFoundException
{
public function __construct(UserId $userId)
{
parent::__construct('User', $userId->toString());
}
public static function byEmail(string $email): self
{
$exception = new self(UserId::generate()); // Dummy ID
$exception->message = "User with email '$email' not found";
return $exception;
}
}
Purpose: Represent technical failures (database, network, external services).
Characteristics:
namespace App\Shared\Infrastructure\Exception;
final class DatabaseConnectionException extends PersistenceException
{
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct("Database connection failed: $message", 0, $previous);
}
}
final class UniqueConstraintViolationException extends PersistenceException
{
public function __construct(string $field, string $value, ?\Throwable $previous = null)
{
parent::__construct(
"Unique constraint violation on field '$field' with value '$value'",
0,
$previous
);
}
}
namespace App\Payment\Infrastructure\Exception;
final class PaymentGatewayException extends ExternalServiceException
{
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct("Payment gateway error: $message", 0, $previous);
}
public static function timeout(): self
{
return new self("Payment gateway request timed out");
}
public static function invalidResponse(string $details): self
{
return new self("Invalid response from payment gateway: $details");
}
}
// ❌ BAD: Infrastructure exception leaks to handler
class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
$user = UserFactory::create($command->email, $command->password);
try {
$this->users->save($user);
} catch (UniqueConstraintViolationException $e) {
// ❌ Catching infrastructure exception in application layer
throw new EmailAlreadyExistsException($command->email);
}
}
}
Problem: Application layer shouldn't know about UniqueConstraintViolationException (infrastructure detail).
// ✅ GOOD: Repository translates infrastructure → domain
class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
try {
$this->entityManager->persist($user);
$this->entityManager->flush();
} catch (UniqueConstraintViolationException $e) {
// Check which field violated constraint
if (str_contains($e->getMessage(), 'email')) {
throw new EmailAlreadyExistsException($user->getEmail()->value);
}
// Re-throw as generic persistence exception
throw new PersistenceException("Failed to save user", previous: $e);
} catch (DriverException $e) {
throw new DatabaseConnectionException($e->getMessage(), previous: $e);
}
}
}
// ✅ GOOD: Handler only deals with domain exceptions
class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
$user = UserFactory::create($command->email, $command->password);
$this->users->save($user); // May throw EmailAlreadyExistsException (domain)
}
}
**Benefits:**
- Application layer only knows about domain exceptions
- Easy to change database without affecting application
---
```php
// Repository translates infrastructure → domain
class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
try {
$this->entityManager->persist($order);
$this->entityManager->flush();
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException $e) {
// Translate to domain exception
throw new OrderAlreadyExistsException($order->getId());
} catch (\Doctrine\DBAL\Exception\ConnectionException $e) {
// Translate to infrastructure exception
throw new DatabaseConnectionException($e->getMessage(), $e);
} catch (\Exception $e) {
// Catch-all: wrap in generic exception
throw new PersistenceException("Failed to save order", previous: $e);
}
}
public function findById(OrderId $id): ?Order
{
try {
$order = $this->entityManager->find(Order::class, $id->toString());
if ($order === null) {
throw new OrderNotFoundException($id);
}
return $order;
} catch (\Doctrine\DBAL\Exception\ConnectionException $e) {
throw new DatabaseConnectionException($e->getMessage(), $e);
}
}
}
---
## Handling Exceptions in Handlers
```php
// ✅ GOOD: Let domain exceptions bubble up
class CancelOrderHandler
{
public function __invoke(CancelOrderCommand $command): void
{
$order = $this->orders->findById($command->orderId);
// May throw OrderNotFoundException
$order->cancel();
// May throw CannotCancelShippedOrderException
$this->orders->save($order);
// May throw PersistenceException
}
}
}
// ✅ GOOD: Catch specific exception and provide context
class ProcessPaymentHandler
{
public function __invoke(ProcessPaymentCommand $command): void
{
$order = $this->orders->findById($command->orderId);
try {
$result = $this->paymentProcessor->charge($order->getTotal());
} catch (PaymentGatewayException $e) {
// Transform to domain exception with context
throw new PaymentFailedException(
orderId: $order->getId(),
amount: $order->getTotal(),
reason: $e->getMessage(),
previous: $e
);
}
$order->markAsPaid($result->transactionId);
$this->orders->save($order);
}
}
}
**When to use:** Add business context to infrastructure exceptions.
```php
// ✅ GOOD: Catch exception and attempt recovery
class SendOrderConfirmationHandler
{
public function __invoke(SendOrderConfirmationCommand $command): void
{
$order = $this->orders->findById($command->orderId);
try {
$this->emailSender->send(new OrderConfirmationEmail($order));
} catch (EmailSendingException $e) {
// Log error but don't fail the whole operation
$this->logger->error("Failed to send order confirmation", [
'orderId' => $order->getId(),
'error' => $e->getMessage()
]);
// Queue for retry later
$this->retryQueue->add(new RetryEmailJob($order->getId()));
// Don't throw - order is still confirmed even if email failed
}
}
}
}
}
// ✅ GOOD: Rollback on any exception
class CheckoutOrderHandler
{
public function __invoke(CheckoutOrderCommand $command): void
{
$this->entityManager->beginTransaction();
try {
$order = $this->orders->findById($command->orderId);
// May throw InsufficientStockException
$this->inventory->reserveStock($order->getItems());
// May throw PaymentFailedException
$payment = $this->paymentProcessor->charge($order->getTotal());
$order->confirm($payment->transactionId);
$this->orders->save($order);
$this->entityManager->commit();
} catch (\Exception $e) {
$this->entityManager->rollback();
throw $e; // Re-throw to controller
}
}
}
throw $e; // Re-throw to controller
}
**When to use:** Multiple operations that must succeed together (ACID transaction).
```php
namespace App\Shared\Infrastructure\Http;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
final class ExceptionListener
{
public function __construct(
private LoggerInterface $logger,
private bool $debug = false,
) {}
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
// Log all exceptions
$this->logger->error($exception->getMessage(), [
'exception' => get_class($exception),
'trace' => $exception->getTraceAsString(),
]);
// Map exception to HTTP response
$response = $this->createResponse($exception);
$event->setResponse($response);
}
private function createResponse(\Throwable $exception): JsonResponse
{
// Domain exceptions
if ($exception instanceof ValidationException) {
return new JsonResponse([
'error' => 'Validation failed',
'message' => $exception->getMessage(),
], Response::HTTP_BAD_REQUEST);
}
if ($exception instanceof BusinessRuleException) {
return new JsonResponse([
'error' => 'Business rule violation',
'message' => $exception->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($exception instanceof NotFoundException) {
return new JsonResponse([
'error' => 'Resource not found',
'message' => $exception->getMessage(),
], Response::HTTP_NOT_FOUND);
}
// Infrastructure exceptions
if ($exception instanceof InfrastructureException) {
return new JsonResponse([
'error' => 'Internal server error',
'message' => $this->debug ? $exception->getMessage() : 'An error occurred',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
// HTTP exceptions (Symfony)
if ($exception instanceof HttpExceptionInterface) {
return new JsonResponse([
'error' => $exception->getMessage(),
], $exception->getStatusCode());
}
// Unknown exceptions
return new JsonResponse([
'error' => 'Internal server error',
'message' => $this->debug ? $exception->getMessage() : 'An unexpected error occurred',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
'message' => $this->debug ? $exception->getMessage() : 'An unexpected error occurred',
services:
App\Shared\Infrastructure\Http\ExceptionListener:
arguments:
$debug: '%kernel.debug%'
tags:
- { name: kernel.event_listener, event: kernel.exception }
arguments:
tags:
#[Route('/api/users', methods: ['POST'])]
final class RegisterUserController
{
public function __invoke(Request $request): JsonResponse
{
try {
$command = new RegisterUserCommand(
email: $request->request->get('email'),
password: $request->request->get('password')
);
$this->messageBus->dispatch($command);
return new JsonResponse(['status' => 'created'], Response::HTTP_CREATED);
} catch (InvalidEmailException $e) {
return new JsonResponse([
'error' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
} catch (PasswordTooShortException $e) {
return new JsonResponse([
'error' => $e->getMessage()
], Response::HTTP_BAD_REQUEST);
} catch (EmailAlreadyExistsException $e) {
return new JsonResponse([
'error' => $e->getMessage()
], Response::HTTP_CONFLICT);
} catch (\Exception $e) {
$this->logger->error($e->getMessage());
return new JsonResponse([
'error' => 'Internal server error'
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
}
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
**Drawback:** Repetitive, error-prone, hard to maintain.
```php
class CancelOrderHandlerTest extends TestCase
{
public function test_throws_when_order_already_shipped(): void
{
$order = new Order(OrderId::generate(), OrderStatus::SHIPPED);
$orders = new InMemoryOrderRepository();
$orders->save($order);
$handler = new CancelOrderHandler($orders);
$command = new CancelOrderCommand($order->getId());
$this->expectException(CannotCancelShippedOrderException::class);
$handler($command);
}
}
}
class DoctrineUserRepositoryTest extends KernelTestCase
{
public function test_throws_domain_exception_on_duplicate_email(): void
{
$repository = $this->getContainer()->get(UserRepositoryInterface::class);
$user1 = UserFactory::create('test@example.com', 'password123');
$repository->save($user1);
$user2 = UserFactory::create('test@example.com', 'password456');
$this->expectException(EmailAlreadyExistsException::class);
$repository->save($user2); // Duplicate email
}
}
}
class RegisterUserControllerTest extends WebTestCase
{
public function test_returns_400_on_invalid_email(): void
{
$client = static::createClient();
$client->request('POST', '/api/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'not-an-email',
'password' => 'ValidPass123',
]));
$this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
$this->assertJsonContains(['error' => 'Validation failed']);
}
public function test_returns_409_on_duplicate_email(): void
{
$client = static::createClient();
// Create first user
$client->request('POST', '/api/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'ValidPass123',
]));
// Try to create duplicate
$client->request('POST', '/api/users', [], [], [
'CONTENT_TYPE' => 'application/json',
], json_encode([
'email' => 'test@example.com',
'password' => 'AnotherPass456',
]));
$this->assertResponseStatusCodeSame(Response::HTTP_CONFLICT);
}
}
}
```php
// Domain exceptions
namespace App\Order\Domain\Exception;
final class InsufficientStockException extends BusinessRuleException
{
public function __construct(ProductId $productId, int $requested, int $available)
{
parent::__construct(
"Product {$productId} has insufficient stock: requested $requested, available $available"
);
}
}
final class PaymentFailedException extends BusinessRuleException
{
public function __construct(
public readonly OrderId $orderId,
public readonly Money $amount,
public readonly string $reason,
?\Throwable $previous = null
) {
parent::__construct(
"Payment failed for order {$orderId}: $reason",
previous: $previous
);
}
}
// Handler with exception handling
final readonly class CheckoutOrderHandler
{
public function __construct(
private OrderRepositoryInterface $orders,
private InventoryServiceInterface $inventory,
private PaymentProcessorInterface $payment,
private EntityManagerInterface $entityManager,
private LoggerInterface $logger,
) {}
public function __invoke(CheckoutOrderCommand $command): void
{
$this->entityManager->beginTransaction();
try {
$order = $this->orders->findById($command->orderId);
// May throw OrderNotFoundException
// Reserve inventory
try {
$this->inventory->reserveStock($order->getItems());
} catch (InsufficientStockException $e) {
$this->logger->warning("Insufficient stock", [
'orderId' => $order->getId(),...
How can I help you explore Laravel packages today?