ahmed-bhs / hexagonal-maker-bundle
Hexagonal Architecture Maker Bundle for Symfony - Generate Commands, Queries, and more with CQRS pattern
Installs: 1
Dependents: 0
Suggesters: 0
Security: 0
Stars: 6
Watchers: 3
Forks: 2
Open Issues: 0
Type:symfony-bundle
pkg:composer/ahmed-bhs/hexagonal-maker-bundle
Requires
- php: >=8.1
- ext-json: *
- symfony/console: ^6.4|^7.0
- symfony/framework-bundle: ^6.4|^7.0
- symfony/maker-bundle: ^1.36
- symfony/messenger: ^6.4|^7.0
Requires (Dev)
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^9.5|^10.0
- symfony/phpunit-bridge: ^6.4|^7.0
README
A complete Symfony Maker bundle for generating Hexagonal Architecture (Ports & Adapters) components
โจ 19 maker commands | ๐ Pure Domain | ๐ฏ CQRS Pattern | ๐๏ธ Full Layer Coverage | ๐ Async/Queue Support
Table of Contents
- Quick Start
- 1. Features
- 2. Why Hexagonal Architecture โ ๐ Complete Guide
- 3. Installation
- 4. Complete Architecture Generation
- 5. Available Makers (18 Commands)
- 6. Configuration
- 7. Best Practices
- 8. Additional Resources
- 9. License
Quick Start
# 1. Install composer require ahmedbhs/hexagonal-maker-bundle --dev # 2. Generate a complete module (User Registration example) bin/console make:hexagonal:entity user/account User bin/console make:hexagonal:exception user/account InvalidEmailException bin/console make:hexagonal:value-object user/account Email bin/console make:hexagonal:repository user/account User bin/console make:hexagonal:command user/account register --factory bin/console make:hexagonal:controller user/account CreateUser /users/register bin/console make:hexagonal:form user/account User # 3. Configure Doctrine ORM mapping (see section 7.3) # 4. Start coding your business logic!
Result: Complete hexagonal architecture with pure domain, separated layers, and ready-to-use components! ๐
1. Features
1.1 Core CQRS Components
- Commands - Write operations that modify state (e.g.,
CreateUserCommand) with their handlers (e.g.,CreateUserCommandHandler) decorated with#[AsMessageHandler]for business logic execution - Queries - Read operations that retrieve data (e.g.,
FindUserQuery) with their handlers (e.g.,FindUserQueryHandler) decorated with#[AsMessageHandler]and response DTOs (e.g.,FindUserResponse)
1.2 Complete Maker Commands Summary
18 makers covering all hexagonal layers + tests + events + rapid CRUD:
| Layer | Maker Command | What it generates |
|---|---|---|
| Domain | make:hexagonal:entity |
Domain entities + YAML mapping |
| Domain | make:hexagonal:value-object |
Immutable value objects |
| Domain | make:hexagonal:exception |
Business rule exceptions |
| Domain | make:hexagonal:domain-event |
Domain events |
| Application | make:hexagonal:command |
CQRS commands + handlers |
| Application | make:hexagonal:query |
CQRS queries + handlers + responses |
| Application | make:hexagonal:repository |
Repository port + Doctrine adapter |
| Application | make:hexagonal:input |
Input DTOs with validation |
| Application | make:hexagonal:use-case |
Use cases |
| Application/Infrastructure | make:hexagonal:event-subscriber |
Event subscribers |
| Infrastructure | make:hexagonal:message-handler |
Async message handlers |
| UI | make:hexagonal:controller |
Web controllers |
| UI | make:hexagonal:form |
Symfony forms |
| UI | make:hexagonal:cli-command |
Console commands |
| Tests | make:hexagonal:use-case-test |
Use case tests (KernelTestCase) |
| Tests | make:hexagonal:controller-test |
Controller tests (WebTestCase) |
| Tests | make:hexagonal:cli-command-test |
CLI tests (CommandTester) |
| Config | make:hexagonal:test-config |
Test configuration setup |
| Rapid Dev | make:hexagonal:crud |
Complete CRUD (Entity + 5 UseCases + Controllers + Forms + Tests) |
2. Why Hexagonal Architecture
2.1 What the Founders Say
Alistair Cockburn - Creator of Hexagonal Architecture (2005)
"Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases."
โ Alistair Cockburn, Hexagonal Architecture
On the core principle:
"The hexagon is intended to visually highlight the following:
- (a) There is an inside and an outside to the application
- (b) The number of ports is not two, but many (and variable)
- (c) The number of adapters for any particular port is not one, but many (and variable)"*
On dependencies:
"Create your application to work without either a UI or a database so you can run automated regression-tests against the application, work when the database becomes unavailable, and link applications together without any user involvement."
Robert C. Martin (Uncle Bob) - Creator of Clean Architecture (2012)
On the business logic:
"The business rules are the heart of the software. They carry the code that makes, or saves, money. They are the family jewels. We want to protect them from all forms of complexity and change."
โ Robert C. Martin, Clean Architecture
On frameworks:
"Frameworks are tools to be used, not architectures to be conformed to. If your architecture is based on frameworks, then it cannot be based on your use cases."
On the dependency rule:
"Source code dependencies must point only inward, toward higher-level policies. Nothing in an inner circle can know anything at all about something in an outer circle."
On volatility:
"The less volatile things are, the more they should be depended upon. Business rules change less frequently than technical details, so technical details should depend on business rules, not the other way around."
Eric Evans - Domain-Driven Design (2003)
On isolating the domain:
"The heart of software is its ability to solve domain-related problems for its user. All other features, vital though they may be, support this basic purpose."
โ Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
On the domain model:
"When a significant process or transformation in the domain is not a natural responsibility of an ENTITY or VALUE OBJECT, add an operation to the model as a standalone interface declared as a SERVICE."
Jeffrey Palermo - Onion Architecture (2008)
On dependency direction:
"The fundamental rule is that all code can depend on layers more central, but code cannot depend on layers further out from the core. In other words, all coupling is toward the center."
โ Jeffrey Palermo, The Onion Architecture
On persistence ignorance:
"The application core doesn't know anything about how data is persisted or where data comes from. It defines interfaces for these concerns, and the outer layers implement these interfaces."
2.2 Key Principles from the Masters
| Principle | Author | Meaning |
|---|---|---|
| Dependency Inversion | Uncle Bob | High-level modules should not depend on low-level modules. Both should depend on abstractions. |
| Ports & Adapters | Alistair Cockburn | The core defines ports (interfaces), the outside world provides adapters (implementations). |
| Screaming Architecture | Uncle Bob | Your architecture should scream what the application does, not what framework it uses. |
| Ubiquitous Language | Eric Evans | The code should speak the language of the domain experts, not technical jargon. |
| Isolation | All | Business logic must be isolated from technical concerns (UI, DB, frameworks). |
Quick Summary
Everything is coupled anyway, so why bother?
Hexagonal architecture isn't about eliminating couplingโthat's impossible. It's about controlling the direction of coupling.
The Core Problem with Traditional Architecture
Traditional layered architecture problems:
- โ๏ธ Framework Prison: Business logic tightly coupled to Doctrine/Symfony
- ๐ข Testing Complexity: Every test requires database, 10 min vs 10 sec
- ๐ช๏ธ Lost Business Rules: Rules scattered across 10+ files
- ๐งฑ Cannot Evolve: Adding GraphQL/CLI requires code duplication
- ๐ Cost Predictability: Simple features take 3x longer after 2 years
Hexagonal architecture solution:
- ๐ Pure Domain Isolation: Your business logic lives in pure PHP, zero framework dependencies. Why? Because frameworks become obsolete, but your business rules don't. Isolated domain = no technical debt accumulation, easier to understand (speaks business language, not technical jargon), and survives all technology changes. The secret: Dependency Inversion - the domain defines interfaces (Ports), infrastructure adapts to them
- ๐ฏ Direction Control: Business logic depends on abstractions, infrastructure depends on business
- โก Testing Speed: 1000x faster (in-memory vs database I/O) - 10 min โ 10 sec
- ๐ Technology Freedom: Swap MySQL to MongoDB in days not months (10-20x effort saved)
- ๐ฐ Cost Predictability (The "5-Day Rule"): Features cost consistent time, no technical debt tax
- ๐ Reusability: Same business logic for REST, GraphQL, CLI, gRPC
- ๐๏ธ Craftsmanship Practices: Promotes SOLID principles, DRY (Don't Repeat Yourself), YAGNI (You Aren't Gonna Need It), KISS (Keep It Simple, Stupid), Separation of Concerns (SoC), and design patterns like DTO, Strategy, Factory, Dependency Injection
The Investment Analogy:
- Traditional = Consumer credit: easy at start, debt strangles you later
- Hexagonal = Investment: pay upfront, every feature costs its real price forever
๐ Want to learn more? Read the complete guide with examples, analogies, and decision trees โ
3. Installation
composer require ahmedbhs/hexagonal-maker-bundle
The bundle will auto-register if you use Symfony Flex. Otherwise, add it to config/bundles.php:
return [ // ... AhmedBhs\HexagonalMakerBundle\HexagonalMakerBundle::class => ['dev' => true], ];
4. Complete Architecture Generation
This section shows exactly how to build a complete hexagonal architecture module step by step, with the exact commands to run for each component.
4.1 Scenario: User Account Management Module
Let's build a complete User Account module with all layers of hexagonal architecture.
4.1.1 Step-by-Step Architecture Generation
# LAYER 1: DOMAIN (Core Business Logic - Pure PHP) # ============================================ # 1.1 Create Domain Entity (User aggregate root - PURE, no Doctrine) bin/console make:hexagonal:entity user/account User # 1.2 Create Domain Exceptions (business rule violations) bin/console make:hexagonal:exception user/account InvalidEmailException bin/console make:hexagonal:exception user/account UserAlreadyExistsException # 1.3 Create Value Objects (domain concepts) bin/console make:hexagonal:value-object user/account UserId bin/console make:hexagonal:value-object user/account Email bin/console make:hexagonal:value-object user/account Password # 1.4 Create Repository Port (interface in domain) bin/console make:hexagonal:repository user/account User # LAYER 2: APPLICATION (Use Cases & DTOs) # ============================================ # 2.1 Create Input DTOs (with validation) bin/console make:hexagonal:input user/account RegisterUserInput # 2.2 Create Registration Use Case (Command) bin/console make:hexagonal:command user/account register --factory # 2.3 Create Activation Use Case (Command) bin/console make:hexagonal:command user/account activate # 2.4 Create Find User Use Case (Query) bin/console make:hexagonal:query user/account find-by-id # 2.5 Create List Users Use Case (Query) bin/console make:hexagonal:query user/account list-all # 2.6 Alternative: Create Use Case (instead of Command/Query) bin/console make:hexagonal:use-case user/account RegisterUser # LAYER 3: UI (Primary Adapters - Driving) # ============================================ # 3.1 Create Web Controller bin/console make:hexagonal:controller user/account RegisterUser /users/register # 3.2 Create Symfony Form bin/console make:hexagonal:form user/account User # 3.3 Create CLI Command bin/console make:hexagonal:cli-command user/account RegisterUser app:user:register # LAYER 4: INFRASTRUCTURE (Secondary Adapters - Already generated!) # ============================================ # The Repository adapter was auto-generated in step 1.4 # Located at: Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php # Doctrine YAML mapping auto-generated with entity in step 1.1 # Located at: Infrastructure/Persistence/Doctrine/Orm/Mapping/User.orm.yml
4.1.2 Generated Architecture Structure
After running the commands above, here's your complete hexagonal architecture:
src/User/Account/
โ
โโโ Domain/ # ๐ CORE BUSINESS LOGIC (Pure PHP, ZERO framework deps)
โ โโโ Model/
โ โ โโโ User.php โ make:hexagonal:entity
โ โ
โ โโโ Exception/ โ NEW!
โ โ โโโ InvalidEmailException.php โ make:hexagonal:exception
โ โ โโโ UserAlreadyExistsException.php โ make:hexagonal:exception
โ โ
โ โโโ ValueObject/
โ โ โโโ UserId.php โ make:hexagonal:value-object
โ โ โโโ Email.php โ make:hexagonal:value-object
โ โ โโโ Password.php โ make:hexagonal:value-object
โ โ
โ โโโ Port/ # Interfaces (Ports)
โ โโโ UserRepositoryInterface.php โ make:hexagonal:repository
โ
โโโ Application/ # โ๏ธ USE CASES & DTOs
โ โโโ Input/ โ NEW!
โ โ โโโ RegisterUserInput.php โ make:hexagonal:input
โ โ
โ โโโ UseCase/ โ NEW!
โ โ โโโ RegisterUserUseCase.php โ make:hexagonal:use-case
โ โ
โ โโโ Register/ # CQRS Command
โ โ โโโ RegisterCommand.php โ make:hexagonal:command
โ โ โโโ RegisterCommandHandler.php โ (auto-generated)
โ โ โโโ AccountFactory.php โ (auto-generated with --factory)
โ โ
โ โโโ Activate/
โ โ โโโ ActivateCommand.php โ make:hexagonal:command
โ โ โโโ ActivateCommandHandler.php โ (auto-generated)
โ โ
โ โโโ FindById/ # CQRS Query
โ โ โโโ FindByIdQuery.php โ make:hexagonal:query
โ โ โโโ FindByIdQueryHandler.php โ (auto-generated)
โ โ โโโ FindByIdResponse.php โ (auto-generated)
โ โ
โ โโโ ListAll/
โ โโโ ListAllQuery.php โ make:hexagonal:query
โ โโโ ListAllQueryHandler.php โ (auto-generated)
โ โโโ ListAllResponse.php โ (auto-generated)
โ
โโโ UI/ # ๐ฎ PRIMARY ADAPTERS (Driving) - NEW!
โ โโโ Http/
โ โ โโโ Web/
โ โ โโโ Controller/
โ โ โ โโโ RegisterUserController.php โ make:hexagonal:controller
โ โ โ
โ โ โโโ Form/
โ โ โโโ UserType.php โ make:hexagonal:form
โ โ
โ โโโ Cli/
โ โโโ RegisterUserCommand.php โ make:hexagonal:cli-command
โ
โโโ Infrastructure/ # ๐ SECONDARY ADAPTERS (Driven)
โโโ Persistence/
โโโ Doctrine/
โโโ Orm/
โ โโโ Mapping/
โ โโโ User.orm.yml โ Auto-generated with entity (YAML mapping)
โ
โโโ DoctrineUserRepository.php โ make:hexagonal:repository (Adapter)
4.1.3 Understanding the Architecture
| Layer | Responsibility | Dependencies | Makers Available |
|---|---|---|---|
| ๐ Domain | Business logic, rules, invariants | ZERO (Pure PHP) | make:hexagonal:entitymake:hexagonal:value-objectmake:hexagonal:exceptionmake:hexagonal:repository (Port) |
| โ๏ธ Application | Use cases, orchestration, DTOs | Domain only | make:hexagonal:commandmake:hexagonal:querymake:hexagonal:use-casemake:hexagonal:input |
| ๐ฎ UI | HTTP/CLI interfaces (Primary Adapters) | Application + Domain | make:hexagonal:controllermake:hexagonal:formmake:hexagonal:cli-command |
| ๐ Infrastructure | DB/API implementation (Secondary Adapters) | Domain (implements Ports) | make:hexagonal:repository (Adapter)Auto: Doctrine YAML mapping |
4.2 Dependency Flow (Hexagonal Rule)
%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%%
graph TB
subgraph UI["๐ฎ UI / Controllers"]
HTTP["๐ HTTP Controllers"]
CLI["โจ๏ธ bin/console Commands"]
end
subgraph APP["โ๏ธ APPLICATION LAYER"]
Commands["๐จ Commands & Queries<br/><small>Use Cases</small>"]
Reg["โข RegisterCommand"]
Find["โข FindByIdQuery"]
Commands --- Reg
Commands --- Find
end
subgraph DOMAIN["๐ DOMAIN LAYER - CORE"]
Entities["๐ฆ Entities & Value Objects"]
EntList["โข User<br/>โข Email, UserId"]
Ports["๐ Ports<br/><small>Interfaces</small>"]
PortList["โข UserRepositoryInterface"]
Entities --- EntList
Ports --- PortList
end
subgraph INFRA["๐ INFRASTRUCTURE LAYER"]
Adapters["๐ง Adapters<br/><small>Implementations</small>"]
AdList["โข DoctrineUserRepository"]
Adapters --- AdList
end
UI ==>|"uses"| APP
APP ==>|"depends on"| DOMAIN
INFRA -.->|"๐ฏ implements"| Ports
style DOMAIN fill:#C8E6C9,stroke:#2E7D32,stroke-width:4px,color:#000
style APP fill:#B3E5FC,stroke:#0277BD,stroke-width:3px,color:#000
style INFRA fill:#F8BBD0,stroke:#C2185B,stroke-width:3px,color:#000
style UI fill:#E1BEE7,stroke:#6A1B9A,stroke-width:3px,color:#000
style Commands fill:#E1F5FE,stroke:#01579B,stroke-width:2px,color:#000
style Entities fill:#E8F5E9,stroke:#1B5E20,stroke-width:2px,color:#000
style Ports fill:#FFF9C4,stroke:#F57F17,stroke-width:2px,color:#000
style Adapters fill:#FCE4EC,stroke:#880E4F,stroke-width:2px,color:#000
Loading
Key Points:
make:hexagonal:command/make:hexagonal:queryโ Application Layermake:hexagonal:entity/make:hexagonal:value-objectโ Domain Layermake:hexagonal:repositoryโ Port (Domain) + Adapter (Infrastructure)
4.3 Quick Start: 5-Command Complete Module
Want to generate a complete module in just 5 commands? Here's a copy-paste ready script:
# Context: Product Catalog Module
bin/console make:hexagonal:entity product/catalog Product
bin/console make:hexagonal:value-object product/catalog ProductId
bin/console make:hexagonal:repository product/catalog Product
bin/console make:hexagonal:command product/catalog create-product --factory
bin/console make:hexagonal:query product/catalog find-product
Result: Complete Product module with Domain, Application, and Infrastructure layers.
5. Available Makers
Quick reference: 19 makers covering Domain, Application, Infrastructure, UI, and Tests layers.
๐ Click to expand: Detailed maker commands documentation
5.1 Create a Command (Write Operation)
Generate a CQRS Command for state-changing operations:
bin/console make:hexagonal:command user/account register
Generated files:
src/User/Account/Application/Register/
โโโ RegisterCommand.php # The command (DTO)
โโโ RegisterCommandHandler.php # The handler (business logic)
With Factory pattern:
bin/console make:hexagonal:command user/account register --factory
Generated files:
src/User/Account/Application/Register/
โโโ RegisterCommand.php
โโโ RegisterCommandHandler.php # Uses factory
โโโ AccountFactory.php # Domain entity factory
With Tests:
bin/console make:hexagonal:command user/account register --with-tests
Generated files:
src/User/Account/Application/Register/
โโโ RegisterCommand.php
โโโ RegisterCommandHandler.php
tests/Unit/User/Account/Application/Register/
โโโ RegisterCommandHandlerTest.php # Unit test (with mocks)
tests/Integration/User/Account/Application/Register/
โโโ RegisterCommandHandlerTest.php # Integration test (full stack)
With Factory and Tests:
bin/console make:hexagonal:command user/account register --factory --with-tests
5.2 Create a Query (Read Operation)
Generate a CQRS Query for data retrieval:
bin/console make:hexagonal:query user/account find
Generated files:
src/User/Account/Application/Find/
โโโ FindQuery.php # The query (request DTO)
โโโ FindQueryHandler.php # The handler (read logic)
โโโ FindResponse.php # The response (response DTO)
5.3 Create a Repository (Port + Adapter)
Generate a repository interface (Port) and its infrastructure implementation (Adapter):
bin/console make:hexagonal:repository user/account User
Generated files:
src/User/Account/
โโโ Domain/Port/
โ โโโ UserRepositoryInterface.php # Port (interface)
โโโ Infrastructure/Persistence/Doctrine/
โโโ DoctrineUserRepository.php # Adapter (implementation)
5.4 Create a Domain Entity
Generate a domain entity in the core layer:
bin/console make:hexagonal:entity user/account User
Generated files:
src/User/Account/Domain/Model/
โโโ User.php # Domain entity with business logic
5.5 Create a Value Object
Generate an immutable value object:
bin/console make:hexagonal:value-object user/account Email
Generated files:
src/User/Account/Domain/ValueObject/
โโโ Email.php # Immutable value object with validation
5.6 Create a Domain Exception
Generate a business exception in the domain layer:
bin/console make:hexagonal:exception user/account InvalidEmailException
Generated files:
src/User/Account/Domain/Exception/
โโโ InvalidEmailException.php # Domain exception for business rule violations
5.7 Create an Input DTO
Generate an input DTO with validation constraints:
bin/console make:hexagonal:input user/account CreateUserInput
Generated files:
src/User/Account/Application/Input/
โโโ CreateUserInput.php # Input DTO with Symfony Validator constraints
5.8 Create a Use Case
Generate a use case (application service):
bin/console make:hexagonal:use-case user/account CreateUser
Generated files:
src/User/Account/Application/UseCase/
โโโ CreateUserUseCase.php # Use case orchestrating domain logic
5.9 Create a Web Controller (UI Layer)
Generate a web controller for HTTP requests:
bin/console make:hexagonal:controller user/account CreateUser /users/create
Generated files:
src/User/Account/UI/Http/Web/Controller/
โโโ CreateUserController.php # Web controller with routing
5.10 Create a Symfony Form
Generate a Symfony form type:
bin/console make:hexagonal:form user/account User
Generated files:
src/User/Account/UI/Http/Web/Form/
โโโ UserType.php # Symfony form type for web UI
5.11 Create a CLI Command (UI Layer)
Generate a console command:
bin/console make:hexagonal:cli-command user/account CreateUser app:user:create
Generated files:
src/User/Account/UI/Cli/
โโโ CreateUserCommand.php # CLI command for console operations
With UseCase workflow:
bin/console make:hexagonal:cli-command user/account CreateUser app:user:create --with-use-case
Generated files:
src/User/Account/UI/Cli/
โโโ CreateUserCommand.php
src/User/Account/Application/
โโโ UseCase/
โ โโโ CreateUserUseCase.php
โโโ Command/
โ โโโ CreateUserCommand.php
โ โโโ CreateUserCommandHandler.php
โโโ Input/
โโโ CreateUserInput.php
Benefits:
- Avoids duplication between web and CLI interfaces
- Both interfaces use the same UseCase
- Consistent business logic across all entry points
5.12 Create a Use Case Test (Tests)
Generate a test for your use case (Application layer):
bin/console make:hexagonal:use-case-test blog/post CreatePost
Generated files:
tests/Blog/Post/Application/CreatePost/
โโโ CreatePostTest.php # KernelTestCase with repository switching
Key features:
- Extends
KernelTestCasefor full container access - Includes success and validation test methods
- Data providers for parameterized testing
- Helper method to switch between repository implementations (Memory/Doctrine/File)
5.13 Create a Controller Test (Tests)
Generate a test for your web controller (UI layer):
bin/console make:hexagonal:controller-test blog/post CreatePost /posts/create
Generated files:
tests/Blog/Post/UI/Http/Web/Controller/
โโโ CreatePostControllerTest.php # WebTestCase with HTTP client
Key features:
- Extends
WebTestCasefor HTTP testing - Tests page loading and redirects
- Form submission testing with field mapping
- Database state verification
- Automatic cleanup in
setUp()
5.14 Create a CLI Command Test (Tests)
Generate a test for your console command (UI layer):
bin/console make:hexagonal:cli-command-test blog/post CreatePost app:post:create
Generated files:
tests/Blog/Post/UI/Cli/
โโโ CreatePostCommandTest.php # CommandTester for CLI testing
Key features:
- Extends
KernelTestCasewithCommandTester - Tests command execution and exit codes
- Tests arguments and options
- Output verification
- Error handling tests
5.15 Create a Domain Event (Domain Layer)
Generate an immutable domain event:
bin/console make:hexagonal:domain-event order/payment OrderPlaced
Generated files:
src/Order/Payment/Domain/Event/
โโโ OrderPlacedEvent.php # Immutable event representing a business fact
Key features:
- Readonly class for immutability
- Contains only data (no behavior)
- Represents a fact that happened in the domain
- Can be dispatched from entities or use cases
5.16 Create an Event Subscriber (Application or Infrastructure)
Generate an event subscriber with layer choice:
# Application Layer (for business workflow orchestration) bin/console make:hexagonal:event-subscriber order/payment OrderPlaced --layer=application # Infrastructure Layer (for technical concerns) bin/console make:hexagonal:event-subscriber shared/logging Exception --layer=infrastructure
Generated files (Application):
src/Order/Payment/Application/EventSubscriber/
โโโ OrderPlacedSubscriber.php # Orchestrates use cases in response to events
Generated files (Infrastructure):
src/Shared/Infrastructure/EventSubscriber/
โโโ ExceptionSubscriber.php # Handles technical concerns (logging, monitoring)
Key features:
- Application Layer: Orchestrates business workflows, calls use cases
- Infrastructure Layer: Handles framework events, logging, caching
- Implements
EventSubscriberInterface - Auto-configured by Symfony
5.17 Enhanced Form with Auto-Generated Command/Input
Generate a form type with optional Command and Input DTO:
# Standard form only bin/console make:hexagonal:form blog/post Post # Form + Command + Input DTO in one command! bin/console make:hexagonal:form blog/post Post --with-command --action=Create
Generated files (with --with-command):
src/Blog/Post/UI/Http/Web/Form/
โโโ PostType.php # Symfony form type
src/Blog/Post/Application/Input/
โโโ CreatePostInput.php # Input DTO with validation
src/Blog/Post/Application/Command/
โโโ CreatePostCommand.php # Command object
โโโ CreatePostCommandHandler.php # Command handler
Benefits:
- One command generates complete workflow
- Form fields map to Command properties
- Input DTO provides validation layer
- Saves time and ensures consistency
5.18 Generate Complete CRUD Module ๐
The most powerful command in the bundle - generate an entire CRUD module in seconds:
bin/console make:hexagonal:crud blog/post Post --route-prefix=/posts
This single command generates 20+ files across all layers:
๐ฆ Domain Layer (3 files):
- Post.php (Entity)
- PostRepositoryInterface.php (Port)
๐ง Infrastructure Layer (2 files):
- DoctrinePostRepository.php (Adapter)
- Post.orm.yml (Doctrine mapping)
๐ฏ Application Layer (15 files):
- CreatePostUseCase.php + CreatePostCommand.php + CreatePostInput.php
- UpdatePostUseCase.php + UpdatePostCommand.php + UpdatePostInput.php
- DeletePostUseCase.php + DeletePostCommand.php + DeletePostInput.php
- GetPostUseCase.php + GetPostCommand.php + GetPostInput.php
- ListPostUseCase.php + ListPostCommand.php + ListPostInput.php
๐ UI Web Layer (6 files):
- CreatePostController.php
- UpdatePostController.php
- DeletePostController.php
- ShowPostController.php
- ListPostController.php
- PostType.php (Form)
With tests:
bin/console make:hexagonal:crud blog/post Post --with-tests
Generates 30+ files including:
- All UseCase tests (5 files)
- All Controller tests (5 files)
With ID ValueObject:
bin/console make:hexagonal:crud blog/post Post --with-id-vo
Additional file generated:
- PostId.php (ValueObject for typed IDs)
Complete example with all options:
bin/console make:hexagonal:crud blog/post Post \ --route-prefix=/posts \ --with-tests \ --with-id-vo
Generated routes:
GET /posts- List all postsGET /posts/{id}- Show single postGET /posts/new- Create new post formPOST /posts/new- Submit new postGET /posts/{id}/edit- Edit post formPOST /posts/{id}/edit- Submit edited postDELETE /posts/{id}/delete- Delete post
Next steps after generation:
- Add properties to your Entity
- Complete Doctrine ORM mapping
- Configure form fields in PostType.php
- Implement UseCase business logic
- Implement Repository methods
- Run tests (if generated)
Perfect for:
- Rapid prototyping
- Starting new modules
- Learning hexagonal architecture structure
- Scaffolding admin interfaces
5.19 Powerful --with-* Options for Rapid Development โก
All makers support powerful options to generate related files automatically, dramatically speeding up development:
Controller: --with-workflow
Generate complete web workflow in one command:
bin/console make:hexagonal:controller blog/post CreatePost /posts/create --with-workflow
Generates 6 files:
- ๐ฏ CreatePostController.php (UI)
- ๐ฏ PostType.php (Form)
- ๐ฏ CreatePostUseCase.php (Application)
- ๐ฏ CreatePostCommand.php + Handler (Application)
- ๐ฏ CreatePostInput.php (Application)
Impact: Creates complete CRUD workflow instantly!
Entity: --with-repository and --with-id-vo
Generate entity with repository and ID value object:
bin/console make:hexagonal:entity blog/post Post --with-repository --with-id-vo
Generates 5 files:
- ๐ฏ Post.php (Domain Entity)
- ๐ฏ Post.orm.yml (Doctrine Mapping)
- ๐ฏ PostRepositoryInterface.php (Domain Port)
- ๐ฏ DoctrinePostRepository.php (Infrastructure)
- ๐ฏ PostId.php (Value Object)
Impact: Complete entity setup with persistence!
UseCase: --with-test
Generate use case with its test:
bin/console make:hexagonal:use-case blog/post CreatePost --with-test
Generates 2 files:
- ๐ฏ CreatePostUseCase.php (Application)
- ๐ฏ CreatePostTest.php (Tests)
Impact: Encourages TDD from the start!
DomainEvent: --with-subscriber
Generate event with its subscriber:
bin/console make:hexagonal:domain-event order/payment OrderPlaced --with-subscriber
Generates 2 files:
- ๐ฏ OrderPlacedEvent.php (Domain)
- ๐ฏ OrderPlacedSubscriber.php (Application)
Impact: Event-driven architecture ready to use!
Form: --with-command
Already documented in section 5.17
CLI Command: --with-use-case
Generate CLI command with UseCase workflow:
bin/console make:hexagonal:cli-command blog/post CreatePost app:post:create --with-use-case
Generates 4 files:
- ๐ฏ CreatePostCommand.php (UI CLI)
- ๐ฏ CreatePostUseCase.php (Application)
- ๐ฏ CreatePostCommand.php + Handler (Application)
- ๐ฏ CreatePostInput.php (Application)
Impact: Shares business logic between web and CLI interfaces!
Summary Table
| Maker | Option | Generates | Use Case |
|---|---|---|---|
make:hexagonal:controller |
--with-workflow |
Controller + Form + UseCase + Command + Input | Complete web CRUD |
make:hexagonal:cli-command |
--with-use-case |
CLI + UseCase + Command + Input | CLI with business logic |
make:hexagonal:entity |
--with-repository |
Entity + Mapping + Port + Adapter | Entity with persistence |
make:hexagonal:entity |
--with-id-vo |
Entity + ID ValueObject | Typed IDs |
make:hexagonal:use-case |
--with-test |
UseCase + Test | TDD workflow |
make:hexagonal:domain-event |
--with-subscriber |
Event + Subscriber | Event-driven |
make:hexagonal:form |
--with-command |
Form + Command + Input | Form workflow |
make:hexagonal:crud |
--with-tests |
Complete CRUD + All tests | Full module with tests |
make:hexagonal:crud |
--with-id-vo |
Complete CRUD + ID VO | CRUD with typed IDs |
Pro Tip: Combine options for maximum productivity!
# Option 1: Build feature step-by-step (2 commands) bin/console make:hexagonal:entity blog/post Post --with-repository --with-id-vo bin/console make:hexagonal:controller blog/post CreatePost /posts/create --with-workflow # Option 2: Generate entire CRUD module instantly (1 command) โก bin/console make:hexagonal:crud blog/post Post --with-tests --with-id-vo # Option 3: CLI + Web sharing same business logic bin/console make:hexagonal:use-case blog/post CreatePost --with-test bin/console make:hexagonal:controller blog/post CreatePost /posts/create bin/console make:hexagonal:cli-command blog/post CreatePost app:post:create
6. Configuration
Create config/packages/hexagonal_maker.yaml:
hexagonal_maker: # Directory where custom skeleton templates are stored skeleton_dir: '%kernel.project_dir%/config/skeleton' # Root source directory root_dir: 'src' # Root namespace root_namespace: 'App'
7.1 Customizing Templates
7.1 Customizing Templates
You can override default templates by creating your own in config/skeleton/:
config/skeleton/
โโโ src/Module/
โโโ Application/
โ โโโ Command/
โ โ โโโ Command.tpl.php
โ โ โโโ CommandHandler.tpl.php
โ โ โโโ CommandHandlerWithFactory.tpl.php
โ โ โโโ Factory.tpl.php
โ โโโ Query/
โ โโโ Query.tpl.php
โ โโโ QueryHandler.tpl.php
โ โโโ Response.tpl.php
โโโ Domain/
โ โโโ Model/
โ โ โโโ Entity.tpl.php
โ โโโ ValueObject/
โ โ โโโ ValueObject.tpl.php
โ โโโ Port/
โ โโโ RepositoryInterface.tpl.php
โโโ Infrastructure/
โโโ Persistence/
โโโ Doctrine/
โโโ DoctrineRepository.tpl.php
7.2 Testing Strategy
7.2 Testing Strategy
The bundle generates two types of tests when using --with-tests:
7.2.1 Unit Tests
Located in tests/Unit/, these tests:
- Use mocks and stubs for dependencies
- Test business logic in isolation
- Run extremely fast (milliseconds)
- No database, no framework boot
Example:
final class RegisterCommandHandlerTest extends TestCase { public function testHandlerExecutesSuccessfully(): void { $repository = $this->createMock(UserRepositoryInterface::class); $repository->expects($this->once()) ->method('save'); $handler = new RegisterCommandHandler($repository); $handler(new RegisterCommand('test@example.com', 'password')); } }
7.2.2 Integration Tests
Located in tests/Integration/, these tests:
- Use real dependencies (database, services)
- Test the full stack end-to-end
- Verify actual behavior in production-like environment
- Extend
KernelTestCasefor Symfony integration
Example:
final class RegisterCommandHandlerTest extends KernelTestCase { public function testCommandIsHandledSuccessfully(): void { self::bootKernel(); $commandBus = static::getContainer()->get(MessageBusInterface::class); $command = new RegisterCommand('test@example.com', 'password'); $commandBus->dispatch($command); // Verify database changes $repository = static::getContainer()->get(UserRepositoryInterface::class); $user = $repository->findByEmail('test@example.com'); $this->assertNotNull($user); } }
7.2.3 InMemory Repositories
The bundle also generates InMemory repository implementations for faster unit testing:
final class InMemoryUserRepository implements UserRepositoryInterface { private array $users = []; public function save(User $user): void { $this->users[$user->getId()->value] = $user; } public function all(): array { return array_values($this->users); } }
Benefits:
- No database setup required
- Tests run 1000x faster
- Easy to verify state changes
- Perfect for TDD
7.3 Doctrine ORM Integration
7.3 Doctrine ORM Integration
7.3.1 Pure Domain Entities + YAML Mapping
In true Hexagonal Architecture, the Domain layer must remain PURE - completely independent of infrastructure frameworks.
This bundle generates:
- Domain Entity (pure PHP, no Doctrine) - in
Domain/Model/ - Doctrine YAML Mapping (infrastructure concern) - in
Infrastructure/Persistence/Doctrine/Orm/Mapping/
This approach maintains strict separation of concerns and follows DDD best practices.
7.3.2 Generated Files Structure
When you run:
bin/console make:hexagonal:entity user/account User
Two files are generated:
1. Domain Entity (PURE)
<?php // src/User/Account/Domain/Model/User.php declare(strict_types=1); namespace App\User\Account\Domain\Model; /** * ๐ PURE Domain Entity - No framework dependencies * Doctrine mapping is in: * Infrastructure/Persistence/Doctrine/Orm/Mapping/User.orm.yml */ final class User { private string $id; private string $email; private \DateTimeImmutable $createdAt; public function __construct( string $id, string $email, ) { $this->id = $id; $this->email = $email; $this->createdAt = new \DateTimeImmutable(); } public function getId(): string { return $this->id; } // Business logic methods... }
2. Doctrine ORM Mapping (Infrastructure)
# src/User/Account/Infrastructure/Persistence/Doctrine/Orm/Mapping/User.orm.yml App\User\Account\Domain\Model\User: type: entity repositoryClass: App\User\Account\Infrastructure\Persistence\Doctrine\DoctrineUserRepository table: user id: id: type: string length: 36 fields: email: type: string length: 180 unique: true createdAt: type: datetime_immutable column: created_at
7.3.3 Why YAML Mapping in Infrastructure Layer?
This is the correct approach for true Hexagonal Architecture and DDD:
๐ฏ Advantages:
- Pure Domain - Zero framework dependencies in domain entities
- Easy Testing - No need to mock Doctrine infrastructure
- Technology Independence - Switch ORMs without touching domain code
- True Separation - Persistence is an infrastructure detail, not a domain concern
- Follows DDD Principles - Domain model independent of persistence mechanism
Configuration Required:
In config/packages/doctrine.yaml:
doctrine: dbal: url: '%env(resolve:DATABASE_URL)%' orm: auto_generate_proxy_classes: true naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware auto_mapping: true mappings: # Add one mapping per module UserAccount: is_bundle: false type: yml dir: '%kernel.project_dir%/src/User/Account/Infrastructure/Persistence/Doctrine/Orm/Mapping' prefix: 'App\User\Account\Domain\Model' alias: UserAccount # Add more modules as needed: # Product: # is_bundle: false # type: yml # dir: '%kernel.project_dir%/src/Catalog/Product/Infrastructure/Persistence/Doctrine/Orm/Mapping' # prefix: 'App\Catalog\Product\Domain\Model' # alias: Product
7.3.4 YAML Mapping Examples
Here are common YAML mapping patterns you'll use:
Basic Field Types:
fields: # String name: type: string length: 255 # Text (unlimited) description: type: text # Numbers age: type: integer price: type: decimal precision: 10 scale: 2 # Boolean isActive: type: boolean # Dates createdAt: type: datetime_immutable birthDate: type: date_immutable # JSON metadata: type: json # Nullable middleName: type: string length: 255 nullable: true
Unique Constraints:
fields: email: type: string length: 180 unique: true
7.3.5 Entity Identity Strategies
Option 1: UUID (Recommended for DDD)
id: id: type: uuid # Doctrine will automatically use UUID type
Option 2: ULID (Sortable UUID)
id: id: type: ulid # Doctrine will automatically use ULID type
Option 3: String-based UUID
id: id: type: string length: 36 # Generate UUID in entity constructor
Option 4: Auto-increment
id: id: type: integer generator: strategy: AUTO
7.3.6 Associations (Relationships)
One-to-Many:
oneToMany: orders: targetEntity: App\Domain\Order\Order mappedBy: user cascade: ['persist', 'remove']
Many-to-One:
manyToOne: category: targetEntity: App\Domain\Category\Category inversedBy: products joinColumn: name: category_id referencedColumnName: id nullable: false
Many-to-Many:
manyToMany: tags: targetEntity: App\Domain\Tag\Tag inversedBy: products joinTable: name: product_tag joinColumns: product_id: referencedColumnName: id inverseJoinColumns: tag_id: referencedColumnName: id
7.3.7 Embedded Value Objects
Address.orm.yml (Value Object):
App\Domain\ValueObject\Address: type: embeddable fields: street: type: string length: 255 city: type: string length: 100 zipCode: type: string length: 10
User.orm.yml (Entity using embedded):
App\Domain\Model\User: type: entity table: user # ... other fields ... embedded: address: class: App\Domain\ValueObject\Address columnPrefix: address_
7.3.8 Database Schema Generation
After creating/modifying YAML mapping files:
# 1. Validate mapping files bin/console doctrine:schema:validate # 2. Generate migration from mapping changes bin/console doctrine:migrations:diff # 3. Review the generated migration in migrations/ # Then execute it: bin/console doctrine:migrations:migrate # For development only - direct schema update (skip migrations) bin/console doctrine:schema:update --force
7.3.9 Complete Reference
For complete YAML mapping reference, see:
- Doctrine YAML Mapping Documentation
- Generated mapping file template in:
Infrastructure/Persistence/Doctrine/Orm/Mapping/ - Configuration guide:
Infrastructure/Persistence/Doctrine/Orm/Mapping/DOCTRINE_CONFIGURATION.md
7.4 Doctrine Extensions (Gedmo) - Keep Domain Pure ๐ฏ
7.4 Doctrine Extensions (Gedmo) - Keep Domain Pure ๐ฏ
This bundle generates pure domain entities with YAML mapping, making it 100% compatible with Doctrine Extensions (Gedmo) without polluting your domain layer.
7.4.1 Why YAML Mapping for Extensions?
๐ช๏ธ Traditional approach (breaks hexagonal architecture):
use Gedmo\Mapping\Annotation as Gedmo; class Post { #[Gedmo\Slug(fields: ['title'])] // ๐ช๏ธ Domain depends on Gedmo! private string $slug; #[Gedmo\Timestampable(on: 'create')] // ๐ช๏ธ Infrastructure concern in Domain! private \DateTimeInterface $createdAt; }
๐ฏ Hexagonal approach (domain stays pure):
// Domain entity - PURE PHP class Post { private string $slug; // ๐ฏ No Gedmo dependency private \DateTimeInterface $createdAt; public function __construct(string $title) { $this->title = $title; // slug and createdAt managed automatically by Gedmo via YAML } }
# Infrastructure YAML mapping - Configuration separated fields: slug: type: string gedmo: slug: fields: [title] createdAt: type: datetime_immutable gedmo: timestampable: on: create
7.4.2 Installation
composer require stof/doctrine-extensions-bundle
7.4.3 Configuration
Enable extensions in config/packages/stof_doctrine_extensions.yaml:
stof_doctrine_extensions: default_locale: en_US orm: default: sluggable: true # Auto-generate slugs timestampable: true # Auto-manage created/updated dates softdeleteable: true # Soft delete (logical deletion) blameable: true # Track who created/updated loggable: true # Entity change history translatable: true # Multi-language content tree: true # Nested tree structures
7.4.4 Available Extensions with YAML Examples
1๏ธโฃ Sluggable - Auto-generate URL-friendly slugs
Domain Entity:
final class Post { private string $title; private string $slug; // Managed by Gedmo public function __construct(string $title) { $this->title = $title; // No need to manually set slug! } public function updateTitle(string $title): void { $this->title = $title; // Slug auto-updates when title changes } }
YAML Mapping:
App\Blog\Post\Domain\Model\Post: type: entity fields: title: type: string length: 255 slug: type: string length: 128 unique: true gedmo: slug: fields: [title] # Generate from title updatable: true # Update when title changes separator: '-' # Use hyphens unique: true # Ensure uniqueness
2๏ธโฃ Timestampable - Auto-manage created/updated dates
Domain Entity:
final class Post { private \DateTimeImmutable $createdAt; // Set automatically private \DateTimeImmutable $updatedAt; // Updated automatically public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } }
YAML Mapping:
fields: createdAt: type: datetime_immutable column: created_at gedmo: timestampable: on: create # Set when entity is created updatedAt: type: datetime_immutable column: updated_at gedmo: timestampable: on: update # Update on every change publishedAt: type: datetime_immutable column: published_at nullable: true gedmo: timestampable: on: change # Set when specific field changes field: status value: published # When status becomes 'published'
3๏ธโฃ SoftDeleteable - Logical deletion (keep data)
Domain Entity:
final class Post { private ?\DateTimeImmutable $deletedAt; // Managed by Gedmo public function isDeleted(): bool { return $this->deletedAt !== null; } }
YAML Mapping:
App\Blog\Post\Domain\Model\Post: type: entity gedmo: soft_deleteable: field_name: deletedAt # Field to mark deletion time_aware: false # Set to true to filter by date fields: deletedAt: type: datetime_immutable column: deleted_at nullable: true
Usage:
// Soft delete (sets deletedAt, doesn't remove from DB) $entityManager->remove($post); $entityManager->flush(); // Soft-deleted entities are automatically excluded from queries $posts = $repository->findAll(); // Excludes deleted posts // To include deleted entities $repository->createQueryBuilder('p') ->getQuery() ->setHint( \Gedmo\SoftDeleteable\Query\TreeWalker\SoftDeleteableWalker::HINT_SOFT_DELETED, true );
4๏ธโฃ Blameable - Track who created/updated
Domain Entity:
final class Post { private string $createdBy; // User who created private string $updatedBy; // Last user who updated }
YAML Mapping:
fields: createdBy: type: string length: 255 column: created_by gedmo: blameable: on: create updatedBy: type: string length: 255 column: updated_by gedmo: blameable: on: update publishedBy: type: string length: 255 column: published_by nullable: true gedmo: blameable: on: change field: status value: published
Configure Blameable Listener:
# config/services.yaml services: Gedmo\Blameable\BlameableListener: tags: - { name: doctrine.event_subscriber, connection: default } calls: - [ setUserValue, [ '@security.token_storage' ] ]
5๏ธโฃ Translatable - Multi-language content
Domain Entity:
final class Post { private string $title; // Translatable private string $content; // Translatable private string $locale; // Current locale public function setTranslatableLocale(string $locale): void { $this->locale = $locale; } }
YAML Mapping:
App\Blog\Post\Domain\Model\Post: type: entity gedmo: translation: entity: Gedmo\Translatable\Entity\Translation locale: locale fields: title: type: string length: 255 gedmo: translatable: ~ # This field is translatable content: type: text gedmo: translatable: ~ locale: type: string length: 5 gedmo: locale: ~ # Stores current locale
Usage:
// Create post in English $post = new Post('Hello World', 'Content in English'); $entityManager->persist($post); $entityManager->flush(); // Add French translation $post->setTranslatableLocale('fr'); $post->setTitle('Bonjour le monde'); $post->setContent('Contenu en franรงais'); $entityManager->persist($post); $entityManager->flush(); // Retrieve in specific language $repository->findTranslationsByLocale($post, 'fr');
6๏ธโฃ Tree (Nested Set) - Hierarchical structures
Domain Entity:
final class Category { private int $lft; // Left value private int $lvl; // Level private int $rgt; // Right value private ?int $root; // Root id private ?self $parent; // Parent category private Collection $children; // Child categories }
YAML Mapping:
App\Category\Domain\Model\Category: type: entity gedmo: tree: type: nested # Use Nested Set algorithm fields: name: type: string length: 255 lft: type: integer gedmo: tree_left: ~ lvl: type: integer gedmo: tree_level: ~ rgt: type: integer gedmo: tree_right: ~ root: type: integer nullable: true gedmo: tree_root: ~ manyToOne: parent: targetEntity: App\Category\Domain\Model\Category inversedBy: children joinColumn: name: parent_id referencedColumnName: id onDelete: CASCADE gedmo: tree_parent: ~ oneToMany: children: targetEntity: App\Category\Domain\Model\Category mappedBy: parent
Usage:
// Create tree structure $electronics = new Category('Electronics'); $computers = new Category('Computers'); $laptops = new Category('Laptops'); $computers->setParent($electronics); $laptops->setParent($computers); // Query tree $repository->childrenHierarchy(); // Get full tree $repository->getChildren($electronics); // Get direct children $repository->getPath($laptops); // Get path from root
7๏ธโฃ Loggable - Entity change history
Domain Entity:
final class Post { private string $title; // Versioned private string $content; // Versioned // Changes will be logged automatically }
YAML Mapping:
App\Blog\Post\Domain\Model\Post: type: entity gedmo: loggable: ~ # Enable logging for this entity fields: title: type: string length: 255 gedmo: versioned: ~ # Track changes to this field content: type: text gedmo: versioned: ~
Usage:
// Changes are logged automatically $post->setTitle('New Title'); $entityManager->flush(); // Retrieve change history $logEntries = $entityManager ->getRepository(Gedmo\Loggable\Entity\LogEntry::class) ->getLogEntries($post); foreach ($logEntries as $log) { echo $log->getAction(); // create, update, remove echo $log->getUsername(); // who made the change echo $log->getLoggedAt(); // when echo $log->getData(); // what changed }
7.4.5 Complete Example: Blog Post with Multiple Extensions
Domain Entity (100% Pure):
<?php declare(strict_types=1); namespace App\Blog\Post\Domain\Model; final class Post { private string $id; private string $title; private string $slug; // Gedmo Sluggable private string $content; private string $status = 'draft'; private \DateTimeImmutable $createdAt; // Gedmo Timestampable private \DateTimeImmutable $updatedAt; // Gedmo Timestampable private ?\DateTimeImmutable $publishedAt = null; // Gedmo Timestampable private ?\DateTimeImmutable $deletedAt = null; // Gedmo SoftDeleteable private string $createdBy; // Gedmo Blameable private string $updatedBy; // Gedmo Blameable public function __construct(string $id, string $title, string $content) { $this->id = $id; $this->title = $title; $this->content = $content; // All Gedmo fields are managed automatically! } public function publish(): void { $this->status = 'published'; // publishedAt will be set automatically by Gedmo } public function updateContent(string $title, string $content): void { $this->title = $title; $this->content = $content; // slug and updatedAt will be updated automatically } // Getters only - no setters for Gedmo-managed fields public function getSlug(): string { return $this->slug; } public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } public function isDeleted(): bool { return $this->deletedAt !== null; } }
YAML Mapping (Infrastructure Configuration):
# src/Blog/Post/Infrastructure/Persistence/Doctrine/Orm/Mapping/Post.orm.yml App\Blog\Post\Domain\Model\Post: type: entity repositoryClass: App\Blog\Post\Infrastructure\Persistence\Doctrine\DoctrinePostRepository table: post gedmo: soft_deleteable: field_name: deletedAt loggable: ~ id: id: type: string length: 36 fields: title: type: string length: 255 gedmo: versioned: ~ slug: type: string length: 128 unique: true gedmo: slug: fields: [title] updatable: true unique: true content: type: text gedmo: versioned: ~ status: type: string length: 20 createdAt: type: datetime_immutable column: created_at gedmo: timestampable: on: create updatedAt: type: datetime_immutable column: updated_at gedmo: timestampable: on: update publishedAt: type: datetime_immutable column: published_at nullable: true gedmo: timestampable: on: change field: status value: published deletedAt: type: datetime_immutable column: deleted_at nullable: true createdBy: type: string length: 255 column: created_by gedmo: blameable: on: create updatedBy: type: string length: 255 column: updated_by gedmo: blameable: on: update
7.4.6 Benefits of YAML-based Extensions
| Benefit | Description |
|---|---|
| ๐ฏ Pure Domain | Zero framework/library dependencies in domain entities |
| ๐ฏ Technology Independence | Easy to switch from Gedmo to another solution |
| ๐ฏ Easy Testing | Domain entities remain simple POPOs (Plain Old PHP Objects) |
| ๐ฏ Clear Separation | Infrastructure concerns stay in Infrastructure layer |
| ๐ฏ True Hexagonal | Respects dependency inversion principle |
| ๐ฏ All Extensions Work | Full compatibility with all Gedmo extensions |
7.4.7 References
7.5 Infrastructure Organization ๐๏ธ
7.5 Infrastructure Organization ๐๏ธ
The Infrastructure layer contains Secondary Adapters - technical implementations of ports (interfaces) defined in the Domain.
7.5.1 Recommended Structure
Infrastructure/
โโโ Persistence/ โ Database adapters
โ โโโ Doctrine/ โ Doctrine ORM implementation
โ โ โโโ DoctrineUserRepository.php
โ โ โโโ Orm/
โ โ โโโ Mapping/
โ โ โโโ User.orm.yml
โ โโโ InMemory/ โ In-memory for testing (optional)
โ โโโ InMemoryUserRepository.php
โโโ Messaging/ โ Async/Queue adapters
โ โโโ Handler/ โ Message handlers (Symfony Messenger)
โ โ โโโ SendWelcomeEmailHandler.php
โ โโโ Publisher/ โ Event publishers
โ โโโ DomainEventPublisher.php
โโโ Email/ โ Email service adapters
โ โโโ SymfonyMailerService.php
โ โโโ SendGridService.php
โโโ Http/ โ HTTP client adapters (external APIs)
โ โโโ StripePaymentClient.php
โ โโโ GoogleMapsClient.php
โโโ Cache/ โ Cache adapters
โ โโโ RedisCacheAdapter.php
โโโ FileStorage/ โ File storage adapters
โ โโโ LocalFilesystemStorage.php
โ โโโ S3Storage.php
โโโ EventSubscriber/ โ Infrastructure event subscribers
โโโ LoggingSubscriber.php
7.5.2 Persistence Layer (Doctrine)
Generated automatically by: make:hexagonal:repository
bin/console make:hexagonal:repository user/account User
Generates:
- ๐ฏ Port:
Domain/Port/UserRepositoryInterface.php - ๐ฏ Adapter:
Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php - ๐ฏ Mapping:
Infrastructure/Persistence/Doctrine/Orm/Mapping/User.orm.yml
Example - Domain Port:
// src/Module/User/Account/Domain/Port/UserRepositoryInterface.php namespace App\Module\User\Account\Domain\Port; interface UserRepositoryInterface { public function save(User $user): void; public function findById(string $id): ?User; }
Example - Infrastructure Adapter:
// src/Module/User/Account/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php namespace App\Module\User\Account\Infrastructure\Persistence\Doctrine; use Doctrine\ORM\EntityManagerInterface; final class DoctrineUserRepository implements UserRepositoryInterface { public function __construct(private EntityManagerInterface $em) {} public function save(User $user): void { $this->em->persist($user); $this->em->flush(); } }
7.5.3 Messaging Layer (Async/Queue) โก
NEW in this bundle! Generate async message handlers for background processing.
Generated by: make:hexagonal:message-handler
# Generate message handler only bin/console make:hexagonal:message-handler user/account SendWelcomeEmail # Generate handler + message class bin/console make:hexagonal:message-handler user/account SendWelcomeEmail --with-message
Generates:
- ๐ฏ Handler:
Infrastructure/Messaging/Handler/SendWelcomeEmailHandler.php - ๐ฏ Message:
Application/Message/SendWelcomeEmailMessage.php(with--with-message)
Example - Message (DTO):
// src/Module/User/Account/Application/Message/SendWelcomeEmailMessage.php namespace App\Module\User\Account\Application\Message; final readonly class SendWelcomeEmailMessage { public function __construct( public string $userId, public string $email, public string $name, ) { } }
Example - Message Handler:
// src/Module/User/Account/Infrastructure/Messaging/Handler/SendWelcomeEmailHandler.php namespace App\Module\User\Account\Infrastructure\Messaging\Handler; use Symfony\Component\Messenger\Attribute\AsMessageHandler; #[AsMessageHandler] final readonly class SendWelcomeEmailHandler { public function __construct( private EmailServiceInterface $emailService, private LoggerInterface $logger, ) { } public function __invoke(SendWelcomeEmailMessage $message): void { $this->emailService->sendWelcomeEmail( to: $message->email, name: $message->name ); $this->logger->info('Welcome email sent', [ 'user_id' => $message->userId, ]); } }
Dispatch message from UseCase:
// src/Module/User/Account/Application/UseCase/CreateUserUseCase.php use Symfony\Component\Messenger\MessageBusInterface; final readonly class CreateUserUseCase { public function __construct( private UserRepositoryInterface $repository, private MessageBusInterface $messageBus, // Inject message bus ) { } public function execute(CreateUserCommand $command): void { $user = new User(...); $this->repository->save($user); // Dispatch async message $this->messageBus->dispatch(new SendWelcomeEmailMessage( userId: $user->getId(), email: $user->getEmail(), name: $user->getName(), )); } }
Configure Messenger (config/packages/messenger.yaml):
framework: messenger: transports: async: '%env(MESSENGER_TRANSPORT_DSN)%' routing: # Route all messages to async transport 'App\Module\User\Account\Application\Message\SendWelcomeEmailMessage': async
Start worker:
bin/console messenger:consume async
7.5.4 Email/Http/Cache/FileStorage Adapters
These adapters are too specific to auto-generate. Create them manually following the Port & Adapter pattern.
Example - Email Adapter:
1. Define Port (Domain):
// src/Module/User/Account/Domain/Port/EmailServiceInterface.php namespace App\Module\User\Account\Domain\Port; interface EmailServiceInterface { public function sendWelcomeEmail(string $to, string $name): void; }
2. Implement Adapter (Infrastructure):
// src/Module/User/Account/Infrastructure/Email/SymfonyMailerService.php namespace App\Module\User\Account\Infrastructure\Email; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mime\Email; final readonly class SymfonyMailerService implements EmailServiceInterface { public function __construct(private MailerInterface $mailer) {} public function sendWelcomeEmail(string $to, string $name): void { $email = (new Email()) ->to($to) ->subject('Welcome!') ->html("<h1>Welcome $name!</h1>"); $this->mailer->send($email); } }
3. Configure Service:
# config/services.yaml services: App\Module\User\Account\Domain\Port\EmailServiceInterface: class: App\Module\User\Account\Infrastructure\Email\SymfonyMailerService
Benefits:
- ๐ฏ Easy to switch from SymfonyMailer to SendGrid (just change config)
- ๐ฏ Easy to mock in tests
- ๐ฏ Domain doesn't know about Symfony
7.6 Shared Kernel Structure ๐
7.6 Shared Kernel Structure ๐
The Shared Kernel contains code reused across multiple modules (bounded contexts).
7.6.1 Recommended Structure
src/
โโโ Module/ โ Modular architecture (bounded contexts)
โ โโโ User/
โ โโโ Blog/
โ โโโ Order/
โโโ Shared/ โ Shared across modules
โโโ Domain/
โ โโโ ValueObject/ โ Shared value objects
โ โ โโโ Uuid.php
โ โ โโโ Email.php
โ โ โโโ DateRange.php
โ โ โโโ Money.php
โ โโโ Exception/ โ Shared domain exceptions
โ โ โโโ NotFoundException.php
โ โ โโโ ValidationException.php
โ โ โโโ DomainException.php
โ โโโ Event/ โ Shared domain events (optional)
โ โโโ DomainEventInterface.php
โโโ Application/
โ โโโ Bus/ โ Bus abstractions
โ โ โโโ CommandBusInterface.php
โ โ โโโ QueryBusInterface.php
โ โ โโโ EventBusInterface.php
โ โโโ UseCase/ โ Shared use case traits
โ โโโ TransactionalTrait.php
โโโ Infrastructure/
โโโ Persistence/
โ โโโ Migrations/ โ Doctrine migrations (centralized)
โ โโโ Version20250106120000.php
โ โโโ Version20250106130000.php
โโโ Bus/
โ โโโ SymfonyCommandBus.php
โ โโโ SymfonyQueryBus.php
โ โโโ SymfonyEventBus.php
โโโ Doctrine/
โโโ Types/ โ Custom Doctrine types
โโโ UuidType.php
โโโ MoneyType.php
7.6.2 Shared Value Objects
Example - Shared Email Value Object:
// src/Shared/Domain/ValueObject/Email.php namespace App\Shared\Domain\ValueObject; final readonly class Email { private string $value; public function __construct(string $value) { if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { throw new \InvalidArgumentException("Invalid email: $value"); } $this->value = strtolower($value); } public function getValue(): string { return $this->value; } public function __toString(): string { return $this->value; } }
Usage in modules:
// Module User use App\Shared\Domain\ValueObject\Email; final class User { public function __construct( private Email $email, // Reuse shared Email VO ) { } } // Module Newsletter use App\Shared\Domain\ValueObject\Email; final class Subscriber { public function __construct( private Email $email, // Same Email VO! ) { } }
7.6.3 Shared Exceptions
// src/Shared/Domain/Exception/NotFoundException.php namespace App\Shared\Domain\Exception; class NotFoundException extends \DomainException { public static function forResource(string $resource, string $id): self { return new self("$resource with ID '$id' not found"); } } // Usage: throw NotFoundException::forResource('User', $userId); throw NotFoundException::forResource('Post', $postId);
7.6.4 Doctrine Migrations (Centralized)
Configure Doctrine Migrations in Shared:
# config/packages/doctrine_migrations.yaml doctrine_migrations: migrations_paths: 'App\Shared\Infrastructure\Persistence\Migrations': 'src/Shared/Infrastructure/Persistence/Migrations' organize_migrations: false all_or_nothing: true
Generate migrations:
bin/console make:migration
Generated in:
src/Shared/Infrastructure/Persistence/Migrations/Version20250106120000.php
Why centralized migrations?
- ๐ฏ Single source of truth for database schema
- ๐ฏ Migrations execute in order (no conflicts between modules)
- ๐ฏ Easier to track schema evolution
- ๐ช๏ธ Modules are slightly coupled through DB schema (acceptable trade-off)
7.6.5 When to Use Shared vs Module
| Component | Shared | Module | Reasoning |
|---|---|---|---|
| Email VO | ๐ฏ | ๐ช๏ธ | Same validation everywhere |
| Money VO | ๐ฏ | ๐ช๏ธ | Same currency logic everywhere |
| Uuid VO | ๐ฏ | ๐ช๏ธ | Generic identifier |
| UserException | ๐ช๏ธ | ๐ฏ | Specific to User module |
| User Entity | ๐ช๏ธ | ๐ฏ | Bounded context specific |
| NotFoundException | ๐ฏ | ๐ช๏ธ | Generic exception |
| Migrations | ๐ฏ | ๐ช๏ธ | Database-wide changes |
| Bus Interfaces | ๐ฏ | ๐ช๏ธ | Application-wide infrastructure |
Golden Rule:
If 3+ modules need the same code โ Move to Shared If only 1-2 modules need it โ Keep in Module
7.6.6 Benefits of Shared Kernel
| Benefit | Description |
|---|---|
| ๐ฏ DRY Principle | Avoid duplicating Email, Uuid, Money across modules |
| ๐ฏ Consistency | Same validation logic everywhere |
| ๐ฏ Maintainability | Fix once, applies everywhere |
| ๐ Coupling | Modules depend on Shared (acceptable trade-off) |
7.6.7 References
7. Best Practices
See ARCHITECTURE-EN.md - Best Practices | ARCHITECTURE.md - Bonnes pratiques (FR) for detailed best practices with code examples.
Quick summary:
- Keep Domain pure (zero framework dependencies)
- Use Value Objects (immutable with
readonly) - CQRS separation (Commands change state, Queries read data)
- Port/Adapter pattern (interfaces in domain, implementations in infrastructure)
- Factories for complex creation
8. Additional Resources
Documentation
- Complete Architecture Guide | Guide Complet d'Architecture (FR) - Detailed explanation of hexagonal architecture concepts with diagrams
- SOLID Principles Guide | Guide des Principes SOLID (FR) - How hexagonal architecture respects SOLID principles
- Practical Examples | Exemples Pratiques (FR) - Complete real-world examples with full code
Learn More
- Doctrine YAML Mapping Reference
- Hexagonal Architecture (Alistair Cockburn)
- Domain-Driven Design (Eric Evans)
9. License
This software is published under the MIT License.
