ahmed-bhs/hexagonal-maker-bundle
layout: default
Understanding how a request flows through all layers of hexagonal architecture is crucial. This guide shows the complete journey from HTTP request to database and back, with data transformations at each boundary.
HTTP JSON → Controller → Command DTO → Handler → Domain Entity → Repository Port → Doctrine Adapter → Database → Entity → Query Result → Response DTO → Controller → HTTP JSON
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'13px'}}}%%
sequenceDiagram
autonumber
participant Client as 🌐 Client<br/>(Browser/API)
participant Router as 🚦 Symfony Router
participant Ctrl as 🎮 Controller<br/>(Infrastructure)
participant Valid as ✅ Validator<br/>(Symfony)
participant Bus as 🚌 Message Bus<br/>(Symfony)
participant Handler as ⚙️ Handler<br/>(Application)
participant Factory as 🏭 Factory<br/>(Domain)
participant Entity as 💎 Entity<br/>(Domain)
participant Port as 🔌 Port<br/>(Domain Interface)
participant Adapter as 🔧 Adapter<br/>(Infrastructure)
participant DB as 🗄️ Database<br/>(PostgreSQL)
rect rgb(255, 240, 240)
Note over Client,Router: INCOMING REQUEST
Client->>Router: POST /api/users<br/>{"email": "user@example.com", "password": "secret123"}
Router->>Ctrl: Route to RegisterUserController
end
rect rgb(240, 248, 255)
Note over Ctrl,Valid: INFRASTRUCTURE LAYER: Input Validation
Ctrl->>Ctrl: Deserialize JSON to RegisterUserRequest DTO
Ctrl->>Valid: Validate DTO constraints
Valid-->>Ctrl: Validation OK
Ctrl->>Bus: Create RegisterUserCommand<br/>dispatch(command)
end
rect rgb(240, 255, 240)
Note over Bus,Handler: APPLICATION LAYER: Orchestration
Bus->>Handler: __invoke(RegisterUserCommand)
Handler->>Port: $this->users->existsByEmail()
Port->>Adapter: existsByEmail()
Adapter->>DB: SELECT COUNT(*) FROM users WHERE email = ?
DB-->>Adapter: 0
Adapter-->>Port: false
Port-->>Handler: false (email available)
end
rect rgb(255, 255, 240)
Note over Handler,Entity: DOMAIN LAYER: Business Logic
Handler->>Factory: UserFactory::create(email, password)
Factory->>Entity: new Email(value)
Entity->>Entity: validate email format
Entity-->>Factory: Email created
Factory->>Entity: HashedPassword::fromPlaintext()
Entity->>Entity: hash password + validate length
Entity-->>Factory: HashedPassword created
Factory->>Entity: new User(id, email, password)
Entity->>Entity: apply business rules
Entity-->>Factory: User entity
Factory-->>Handler: User entity
end
rect rgb(240, 248, 255)
Note over Handler,DB: INFRASTRUCTURE LAYER: Persistence
Handler->>Port: $this->users->save($user)
Port->>Adapter: save($user)
Adapter->>DB: INSERT INTO users (...) VALUES (...)
DB-->>Adapter: OK
Adapter-->>Port: void
Port-->>Handler: void
end
rect rgb(255, 240, 240)
Note over Handler,Client: RESPONSE PATH
Handler-->>Bus: void (success)
Bus-->>Ctrl: void
Ctrl->>Ctrl: Create UserResponse DTO<br/>from User entity
Ctrl-->>Router: Response(201, UserResponse)
Router-->>Client: 201 Created<br/>{"id": "123", "email": "user@example.com"}
end
Input: POST /api/users HTTP/1.1
Content-Type: application/json
{"email": "user@example.com", "password": "secret123"}
Action: Symfony Router matches route → RegisterUserController
// Controller receives raw request
public function __invoke(Request $request): JsonResponse
{
// Deserialize JSON to DTO
$dto = $this->serializer->deserialize(
$request->getContent(),
RegisterUserRequest::class,
'json'
);
// $dto is now: RegisterUserRequest {
// email: "user@example.com",
// password: "secret123"
// }
}
Raw JSON String → RegisterUserRequest DTO (Infrastructure)
// Validate using Symfony constraints
$errors = $this->validator->validate($dto);
if (count($errors) > 0) {
throw new ValidationException($errors);
}
// DTO class with constraints:
class RegisterUserRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password;
}
// Transform DTO → Command (Application DTO)
$command = new RegisterUserCommand(
email: $dto->email,
password: $dto->password
);
// Dispatch to message bus
$this->messageBus->dispatch($command);
RegisterUserRequest DTO → RegisterUserCommand DTO (Application)
// Symfony automatically invokes handler
#[AsMessageHandler]
final readonly class RegisterUserHandler
{
public function __invoke(RegisterUserCommand $command): void
{
// Handler starts orchestration
}
}
// Handler calls port
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException($command->email);
}
// Port interface (Domain)
interface UserRepositoryInterface
{
public function existsByEmail(string $email): bool;
}
// Adapter implementation (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function existsByEmail(string $email): bool
{
$qb = $this->entityManager->createQueryBuilder();
$qb->select('COUNT(u.id)')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email);
return (int) $qb->getQuery()->getSingleScalarResult() > 0;
}
}
// Database query executed:
// SELECT COUNT(id) FROM users WHERE email = 'user@example.com'
Command (email string)
→ Port method call
→ Adapter (Doctrine QueryBuilder)
→ SQL Query
→ Database
→ Result (0)
→ Adapter (false)
→ Port (false)
→ Handler (proceeds)
// Handler delegates creation to factory
$user = UserFactory::create($command->email, $command->password);
// Factory creates Email value object
$email = new Email($command->email);
// Email constructor validates
final readonly class Email
{
public function __construct(public string $value)
{
// Business rule: must be valid email
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
// Business rule: corporate domain only (example)
if (!str_ends_with($value, '[@company](https://github.com/company).com')) {
throw new InvalidEmailDomainException($value);
}
}
}
Primitive string → Email Value Object (Domain)
// Factory creates hashed password
$hashedPassword = HashedPassword::fromPlaintext($command->password);
// Value object handles hashing
final readonly class HashedPassword
{
private function __construct(public string $hash) {}
public static function fromPlaintext(string $plaintext): self
{
// Business rule: minimum length
if (strlen($plaintext) < 8) {
throw new PasswordTooShortException();
}
// Hash the password
$hash = password_hash($plaintext, PASSWORD_ARGON2ID);
return new self($hash);
}
}
Plaintext string → HashedPassword Value Object (Domain)
// Factory creates entity with all value objects
public static function create(string $email, string $password): User
{
return new User(
id: UserId::generate(),
email: new Email($email),
password: HashedPassword::fromPlaintext($password),
isActive: false,
createdAt: new \DateTimeImmutable()
);
}
// Entity constructor applies business rules
public function __construct(
private UserId $id,
private Email $email,
private HashedPassword $password,
private bool $isActive,
private \DateTimeImmutable $createdAt,
) {
// Business invariant: new users are inactive
if ($this->isActive) {
throw new NewUserCannotBeActiveException();
}
}
Primitives (string, string)
→ Value Objects (Email, HashedPassword)
→ Entity (User) [Domain]
// Handler saves entity through port
$this->users->save($user);
// Port interface (Domain)
interface UserRepositoryInterface
{
public function save(User $user): void;
}
// Adapter implementation (Infrastructure)
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}
// Doctrine generates SQL:
// INSERT INTO users (id, email, password, is_active, created_at)
// VALUES ('550e8400-...', 'user@example.com', '$argon2id$...', false, '2024-01-15 10:30:00')
User Entity (Domain)
→ Doctrine Metadata Mapping
→ SQL INSERT Statement
→ Database Row
// Handler completes (returns void)
public function __invoke(RegisterUserCommand $command): void
{
// ... all steps completed
// No return value (command pattern)
}
// Controller receives void, creates response
public function __invoke(Request $request): JsonResponse
{
$command = new RegisterUserCommand(/*...*/);
$this->messageBus->dispatch($command);
// Fetch created user to return
$user = $this->users->findByEmail($command->email);
// Transform Entity → Response DTO
$response = new UserResponse(
id: $user->getId()->toString(),
email: $user->getEmail()->value,
isActive: $user->isActive(),
createdAt: $user->getCreatedAt()->format('c')
);
return new JsonResponse($response, Response::HTTP_CREATED);
}
User Entity (Domain) → UserResponse DTO (Infrastructure) → JSON
Output: HTTP/1.1 201 Created
Content-Type: application/json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@example.com",
"isActive": false,
"createdAt": "2024-01-15T10:30:00+00:00"
}
1. Raw JSON (HTTP)
↓
2. RegisterUserRequest DTO (Infrastructure - Input validation)
↓
3. RegisterUserCommand (Application - Use case intent)
↓
4. Email + Password (strings)
↓
5. Email Value Object + HashedPassword Value Object (Domain - Business validation)
↓
6. User Entity (Domain - Business logic)
↓
7. Doctrine Entity Metadata (Infrastructure - ORM mapping)
↓
8. SQL INSERT (Infrastructure - Database)
↓
9. Database Row (Persistence)
↓
10. User Entity (Domain - Loaded from DB)
↓
11. UserResponse DTO (Infrastructure - Output formatting)
↓
12. JSON Response (HTTP)
| Transformation | Purpose | Layer |
|---|---|---|
| JSON → Request DTO | Input validation, HTTP concerns | Infrastructure |
| Request DTO → Command | Use case intent, application concern | Application |
| Command → Value Objects | Business validation | Domain |
| Value Objects → Entity | Business logic encapsulation | Domain |
| Entity → SQL | Persistence mapping | Infrastructure |
| SQL → Database Row | Storage | Infrastructure |
| Entity → Response DTO | Output formatting, hide internals | Infrastructure |
// 1. INFRASTRUCTURE: Controller
namespace App\User\Infrastructure\Controller;
#[Route('/api/users', methods: ['POST'])]
final readonly class RegisterUserController extends AbstractController
{
public function __invoke(Request $request): JsonResponse
{
// Deserialize + validate
$dto = $this->serializer->deserialize(
$request->getContent(),
RegisterUserRequest::class,
'json'
);
$violations = $this->validator->validate($dto);
if (count($violations) > 0) {
throw new ValidationException($violations);
}
// Create command
$command = new RegisterUserCommand(
email: $dto->email,
password: $dto->password
);
// Dispatch
$this->messageBus->dispatch($command);
// Fetch result
$user = $this->users->findByEmail($command->email);
// Create response
return $this->json(
new UserResponse(
id: $user->getId()->toString(),
email: $user->getEmail()->value,
isActive: $user->isActive()
),
Response::HTTP_CREATED
);
}
}
// 2. APPLICATION: Command (DTO)
namespace App\User\Application\Command;
final readonly class RegisterUserCommand
{
public function __construct(
public string $email,
public string $password,
) {}
}
// 3. APPLICATION: Handler
namespace App\User\Application\Handler;
#[AsMessageHandler]
final readonly class RegisterUserHandler
{
public function __construct(
private UserRepositoryInterface $users,
private EventDispatcherInterface $eventDispatcher,
) {}
public function __invoke(RegisterUserCommand $command): void
{
// Check uniqueness (application concern - needs repository)
if ($this->users->existsByEmail($command->email)) {
throw new EmailAlreadyExistsException($command->email);
}
// Create user (domain logic in factory)
$user = UserFactory::create($command->email, $command->password);
// Persist (infrastructure concern)
$this->users->save($user);
// Dispatch event (infrastructure concern)
$this->eventDispatcher->dispatch(
new UserRegisteredEvent($user->getId())
);
}
}
// 4. DOMAIN: Factory
namespace App\User\Domain\Factory;
final class UserFactory
{
public static function create(string $email, string $password): User
{
return new User(
id: UserId::generate(),
email: new Email($email), // Validates format
password: HashedPassword::fromPlaintext($password), // Validates + hashes
isActive: false,
createdAt: new \DateTimeImmutable()
);
}
}
// 5. DOMAIN: Value Objects
namespace App\User\Domain\ValueObject;
final readonly class Email
{
public function __construct(public string $value)
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException($value);
}
}
}
final readonly class HashedPassword
{
private function __construct(public string $hash) {}
public static function fromPlaintext(string $plaintext): self
{
if (strlen($plaintext) < 8) {
throw new PasswordTooShortException();
}
return new self(password_hash($plaintext, PASSWORD_ARGON2ID));
}
}
// 6. DOMAIN: Entity
namespace App\User\Domain\Model;
class User
{
public function __construct(
private UserId $id,
private Email $email,
private HashedPassword $password,
private bool $isActive,
private \DateTimeImmutable $createdAt,
) {}
public function activate(): void
{
if ($this->isActive) {
throw new UserAlreadyActiveException();
}
$this->isActive = true;
}
// Getters...
}
// 7. DOMAIN: Port (Interface)
namespace App\User\Domain\Port;
interface UserRepositoryInterface
{
public function save(User $user): void;
public function existsByEmail(string $email): bool;
public function findByEmail(string $email): ?User;
}
// 8. INFRASTRUCTURE: Adapter (Doctrine Implementation)
namespace App\User\Infrastructure\Persistence;
final class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
}
public function existsByEmail(string $email): bool
{
return $this->entityManager->createQueryBuilder()
->select('COUNT(u.id)')
->from(User::class, 'u')
->where('u.email = :email')
->setParameter('email', $email)
->getQuery()
->getSingleScalarResult() > 0;
}
public function findByEmail(string $email): ?User
{
return $this->entityManager
->getRepository(User::class)
->findOneBy(['email' => $email]);
}
}
sequenceDiagram
participant Client
participant Controller
participant Handler
participant Factory
participant Email
Client->>Controller: POST /api/users<br/>{"email": "invalid", "password": "secret"}
Controller->>Handler: dispatch(command)
Handler->>Factory: create("invalid", "secret")
Factory->>Email: new Email("invalid")
Email->>Email: validate format
Email-->>Factory: ❌ InvalidEmailException
Factory-->>Handler: ❌ InvalidEmailException
Handler-->>Controller: ❌ InvalidEmailException
Controller->>Controller: catch & transform
Controller-->>Client: 400 Bad Request<br/>{"error": "Invalid email format"}
sequenceDiagram
participant Handler
participant Port
participant Adapter
participant DB
Handler->>Port: save(user)
Port->>Adapter: save(user)
Adapter->>DB: INSERT INTO users...
DB-->>Adapter: ❌ Duplicate key violation
Adapter-->>Port: ❌ UniqueConstraintViolationException
Port-->>Handler: ❌ UniqueConstraintViolationException
Handler->>Handler: catch & wrap
Handler-->>Handler: ❌ EmailAlreadyExistsException
// ❌ BAD: N+1 Query Problem
public function listUsers(): array
{
$users = $this->users->findAll(); // 1 query
foreach ($users as $user) {
$user->getOrders(); // N queries!
}
return $users;
}
// ✅ GOOD: Eager Loading
public function listUsers(): array
{
return $this->entityManager->createQueryBuilder()
->select('u', 'o')
->from(User::class, 'u')
->leftJoin('u.orders', 'o')
->getQuery()
->getResult(); // 1 query
}
```php
// Add caching at infrastructure layer
final class CachedUserRepository implements UserRepositoryInterface
{
public function __construct(
private UserRepositoryInterface $decorated,
private CacheInterface $cache,
) {}
public function findByEmail(string $email): ?User
{
return $this->cache->get(
"user:email:{$email}",
fn() => $this->decorated->findByEmail($email)
);
}
}
### Database Connection Pooling
```yaml
doctrine:
dbal:
connections:
default:
url: '%env(resolve:DATABASE_URL)%'
driver: 'pdo_pgsql'
server_version: '15'
options:
# Connection pooling
persistent: true
# Prepared statement caching
cache_prepared_statements: true
---
## Key Takeaways
1. **Layered Transformations:** Data transforms at each boundary to maintain separation
2. **Direction Matters:** Dependencies always point inward (Infrastructure → Application → Domain)
3. **Ports at Boundaries:** All domain access to infrastructure goes through ports
4. **DTOs Everywhere:** Input DTO, Command DTO, Entity, Response DTO—each has a purpose
6. **Performance via Infrastructure:** Caching, query optimization happen in adapters, not domain
---
How can I help you explore Laravel packages today?