Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Hexagonal Maker Bundle Laravel Package

ahmed-bhs/hexagonal-maker-bundle

View on GitHub
Deep Wiki
Context7

layout: default

Error Handling Strategy in Hexagonal Architecture

Table of Contents

  1. Exception Hierarchy
  2. Domain vs Infrastructure Exceptions
  3. Exception Translation at Boundaries
  4. Handling Exceptions in Handlers
  5. Controller Exception Handling
  6. Testing Error Scenarios
  7. Complete Real-World Examples

Exception Hierarchy

Recommended Structure

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

Base Exception Classes

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 {}

Domain vs Infrastructure Exceptions

Domain Exceptions

Purpose: Represent business rule violations or domain-specific errors.

Characteristics:

  • Thrown by domain layer (entities, value objects, domain services)
  • Express business concepts
  • Should be caught and handled by application layer
  • May propagate to controller for user feedback

Example: Domain Validation Exceptions

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");
    }
}

Example: Business Rule Exceptions

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"
        );
    }
}

Example: Not Found Exceptions

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;
    }
}

Infrastructure Exceptions

Purpose: Represent technical failures (database, network, external services).

Characteristics:

  • Thrown by infrastructure layer (repositories, adapters)
  • Express technical problems
  • Should be caught by application layer and translated to domain exceptions or logged
  • Generally should NOT propagate to domain layer

Example: Persistence Exceptions

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
        );
    }
}

Example: External Service Exceptions

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");
    }
}

Exception Translation at Boundaries

The Problem: Infrastructure Exceptions in Domain

// ❌ 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).


Solution: Translate in Repository (Adapter)

// ✅ 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(),...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
terminal42/code-quality-tools
codifyo/ts-generator-bundle
andydefer/laravel-cluster
testo/fiber
mintobit/jobqueue
a4sex/maintenance-bundle
a4sex/entity-date-update
a4sex/client-identifier
a4sex/base-utilites
a4sex/key-value-storage
a4sex/micro-status
chilldev/dependency-injection-extra
datinglibre/datinglibre-app-api
biberltd/corebundle
bricre/symfony-bundle-test
biberltd/logbundle
dominium/http-adapter-bundle
dominium/google-analytics
a4sex/auto-clean-entity
christhompsontldr/laravel-inky