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

v0.1 2026-01-07 22:04 UTC

This package is auto-updated.

Last update: 2026-01-09 14:05:15 UTC


README

Hexagonal Architecture

A complete Symfony Maker bundle for generating Hexagonal Architecture (Ports & Adapters) components

Latest Version CI Status License PHP Version Symfony

โœจ 19 maker commands | ๐Ÿ’Ž Pure Domain | ๐ŸŽฏ CQRS Pattern | ๐Ÿ—๏ธ Full Layer Coverage | ๐Ÿ”„ Async/Queue Support

Table of Contents

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

๐Ÿ“š Read the complete guide: WHY-HEXAGONAL.md

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:entity
make:hexagonal:value-object
make:hexagonal:exception
make:hexagonal:repository (Port)
โš™๏ธ Application Use cases, orchestration, DTOs Domain only make:hexagonal:command
make:hexagonal:query
make:hexagonal:use-case
make:hexagonal:input
๐ŸŽฎ UI HTTP/CLI interfaces (Primary Adapters) Application + Domain make:hexagonal:controller
make:hexagonal:form
make: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 Layer
  • make:hexagonal:entity / make:hexagonal:value-object โ†’ Domain Layer
  • make: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 KernelTestCase for 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 WebTestCase for 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 KernelTestCase with CommandTester
  • 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 posts
  • GET /posts/{id} - Show single post
  • GET /posts/new - Create new post form
  • POST /posts/new - Submit new post
  • GET /posts/{id}/edit - Edit post form
  • POST /posts/{id}/edit - Submit edited post
  • DELETE /posts/{id}/delete - Delete post

Next steps after generation:

  1. Add properties to your Entity
  2. Complete Doctrine ORM mapping
  3. Configure form fields in PostType.php
  4. Implement UseCase business logic
  5. Implement Repository methods
  6. 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 KernelTestCase for 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:

  1. Domain Entity (pure PHP, no Doctrine) - in Domain/Model/
  2. 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

Learn More

9. License

This software is published under the MIT License.