Clean Architecture, Hexagonal Architecture & DDD for PHP/Symfony
Overview
This skill provides guidance for implementing Clean Architecture, Hexagonal Architecture (Ports & Adapters), and Domain-Driven Design patterns in PHP 8.3+ applications using Symfony 7.x. It ensures clear separation of concerns, framework-independent business logic, and highly testable code through layered architecture with inward-only dependencies.
When to Use
Architecting new enterprise PHP applications with Symfony 7.x
Refactoring legacy PHP code to modern, testable patterns
Implementing Domain-Driven Design in PHP projects
Creating maintainable applications with clear separation of concerns
Building testable business logic independent of frameworks
Designing modular PHP systems with swappable infrastructure
Instructions
1. Understand the Architecture Layers
Clean Architecture follows the dependency rule: dependencies only point inward.
| Domain (Entities & Business Rules) | Entities, Value Objects, Domain Events
+-------------------------------------+
Hexagonal Architecture (Ports & Adapters)
:
Domain Core
Business logic, framework-agnostic
Ports
Interfaces (e.g.,
UserRepositoryInterface
)
Adapters
Concrete implementations (Doctrine, InMemory for tests)
DDD Tactical Patterns
:
Entities
Objects with identity (e.g.,
User
,
Order
)
Value Objects
Immutable, defined by attributes (e.g.,
Email
,
Money
)
Aggregates
Consistency boundaries with root entity
Domain Events
Capture business occurrences
Repositories
Persist/retrieve aggregates
2. Organize Directory Structure
Create the following directory structure to enforce layer separation:
src/
+-- Domain/ # Innermost layer - no dependencies
| +-- Entity/
| | +-- User.php
| | +-- Order.php
| +-- ValueObject/
| | +-- Email.php
| | +-- Money.php
| | +-- OrderId.php
| +-- Repository/
| | +-- UserRepositoryInterface.php
| +-- Event/
| | +-- UserCreatedEvent.php
| +-- Exception/
| +-- DomainException.php
+-- Application/ # Use cases - depends on Domain
| +-- Command/
| | +-- CreateUserCommand.php
| | +-- UpdateOrderCommand.php
| +-- Handler/
| | +-- CreateUserHandler.php
| | +-- UpdateOrderHandler.php
| +-- Query/
| | +-- GetUserQuery.php
| +-- Dto/
| | +-- UserDto.php
| +-- Service/
| +-- NotificationServiceInterface.php
+-- Adapter/ # Interface adapters
| +-- Http/
| | +-- Controller/
| | | +-- UserController.php
| | +-- Request/
| | +-- CreateUserRequest.php
| +-- Persistence/
| +-- Doctrine/
| +-- Repository/
| | +-- DoctrineUserRepository.php
| +-- Mapping/
| +-- User.orm.xml
+-- Infrastructure/ # Framework & external concerns
+-- Config/
| +-- services.yaml
+-- Event/
| +-- SymfonyEventDispatcher.php
+-- Service/
+-- SendgridEmailService.php
3. Implement Domain Layer
Start from the innermost layer (Domain) and work outward:
Create Value Objects
with validation at construction time - they must be immutable using PHP 8.1+
readonly
Create Entities
with domain logic and business rules - entities should encapsulate behavior, not just be data bags
Define Repository Interfaces
(Ports) - keep them small and focused
Define Domain Events
to decouple side effects from core business logic
4. Implement Application Layer
Build use cases that orchestrate domain objects:
Create Commands
as readonly DTOs representing write operations
Create Queries
for read operations (CQRS pattern)
Implement Handlers
that receive commands/queries and coordinate domain objects
Define Service Interfaces
for external dependencies (notifications, etc.)
5. Implement Adapter Layer
Create interface adapters that connect Application to Infrastructure:
Create Controllers
that receive HTTP requests and invoke handlers
Create Request DTOs
with Symfony validation attributes
Implement Repository Adapters
that bridge domain interfaces to persistence layer
6. Configure Infrastructure
Set up framework-specific configuration:
Configure Symfony DI
to bind interfaces to implementations
Create test doubles
(In-Memory repositories) for unit testing without database
Configure Doctrine mappings
for persistence
7. Test Without Framework
Ensure Domain and Application layers are testable without Symfony, Doctrine, or database. Use In-Memory repositories for fast unit tests.
Examples
Example 1: Value Object with Validation
value
;
}
public
function
equals
(
self
$other
)
:
bool
{
return
$this
->
value
===
$other
->
value
;
}
public
function
domain
(
)
:
string
{
return
substr
(
$this
->
value
,
strrpos
(
$this
->
value
,
'@'
)
+
1
)
;
}
}
Example 2: Entity with Domain Logic
recordEvent
(
new
UserCreatedEvent
(
$id
->
value
(
)
)
)
;
}
public
static
function
create
(
UserId
$id
,
Email
$email
,
string
$name
)
:
self
{
return
new
self
(
$id
,
$email
,
$name
,
new
DateTimeImmutable
(
)
)
;
}
public
function
deactivate
(
)
:
void
{
$this
->
isActive
=
false
;
}
public
function
canPlaceOrder
(
)
:
bool
{
return
$this
->
isActive
;
}
public
function
id
(
)
:
UserId
{
return
$this
->
id
;
}
public
function
email
(
)
:
Email
{
return
$this
->
email
;
}
public
function
domainEvents
(
)
:
array
{
return
$this
->
domainEvents
;
}
public
function
clearDomainEvents
(
)
:
void
{
$this
->
domainEvents
=
[
]
;
}
private
function
recordEvent
(
object
$event
)
:
void
{
$this
->
domainEvents
[
]
=
$event
;
}
}
Example 3: Repository Port (Interface)
email
)
;
if
(
$this
->
userRepository
->
findByEmail
(
$email
)
!==
null
)
{
throw
new
InvalidArgumentException
(
'User with this email already exists'
)
;
}
$user
=
User
::
create
(
new
UserId
(
$command
->
id
)
,
$email
,
$command
->
name
)
;
$this
->
userRepository
->
save
(
$user
)
;
}
}
Example 5: Symfony Controller
toRfc4122
(
)
,
email
:
$request
->
email
,
name
:
$request
->
name
)
;
(
$this
->
createUserHandler
)
(
$command
)
;
return
new
JsonResponse
(
[
'id'
=>
$command
->
id
]
,
201
)
;
}
}
Example 6: Request DTO with Validation
entityManager
->
getRepository
(
User
::
class
)
->
find
(
$id
->
value
(
)
)
;
}
public
function
findByEmail
(
Email
$email
)
:
?
User
{
return
$this
->
entityManager
->
getRepository
(
User
::
class
)
->
findOneBy
(
[
'email.value'
=>
$email
->
value
(
)
]
)
;
}
public
function
save
(
User
$user
)
:
void
{
$this
->
entityManager
->
persist
(
$user
)
;
$this
->
entityManager
->
flush
(
)
;
}
public
function
delete
(
UserId
$id
)
:
void
{
$user
=
$this
->
findById
(
$id
)
;
if
(
$user
!==
null
)
{
$this
->
entityManager
->
remove
(
$user
)
;
$this
->
entityManager
->
flush
(
)
;
}
}
}
Example 8: Symfony DI Configuration
# config/services.yaml
services
:
_defaults
:
autowire
:
true
autoconfigure
:
true
App\
:
resource
:
'../src/'
exclude
:
-
'../src/Domain/Entity/'
-
'../src/Kernel.php'
# Repository binding - Port to Adapter
App\Domain\Repository\UserRepositoryInterface
:
class
:
App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository
# In-memory repository for tests
App\Domain\Repository\UserRepositoryInterface $inMemoryUserRepository
:
class
:
App\Tests\Infrastructure\Repository\InMemoryUserRepository
Example 9: In-Memory Repository for Testing
users
[
$id
->
value
(
)
]
??
null
;
}
public
function
findByEmail
(
Email
$email
)
:
?
User
{
foreach
(
$this
->
users
as
$user
)
{
if
(
$user
->
email
(
)
->
equals
(
$email
)
)
{
return
$user
;
}
}
return
null
;
}
public
function
save
(
User
$user
)
:
void
{
$this
->
users
[
$user
->
id
(
)
->
value
(
)
]
=
$user
;
}
public
function
delete
(
UserId
$id
)
:
void
{
unset
(
$this
->
users
[
$id
->
value
(
)
]
)
;
}
}
Best Practices
Dependency Rule
: Dependencies only point inward - domain knows nothing of application or infrastructure
Immutability
: Value Objects MUST be immutable using
readonly
in PHP 8.1+ - never allow mutable state
Rich Domain Models
: Put business logic in entities with factory methods like
create()
- avoid anemic models
Interface Segregation
: Keep repository interfaces small and focused - do not create god interfaces
Framework Independence
: Domain and application layers MUST be testable without Symfony or Doctrine
Validation at Construction
: Validate in Value Objects at construction time - never allow invalid state
Symfony Attributes
: Use PHP 8 attributes for routing (
#[Route]
), validation (
#[Assert\]
), and DI
Test Doubles
: Always provide In-Memory implementations for repositories to enable fast unit tests
Domain Events
: Dispatch domain events to decouple side effects - do not call external services from entities
XML/YAML Mappings
: Use XML or YAML for Doctrine mappings instead of annotations in domain entities
Constraints and Warnings
Architecture Constraints
Dependency Rule
: Dependencies only point inward. Domain knows nothing of Application, Application knows nothing of Infrastructure. Violating this breaks the architecture.
No Anemic Domain
: Entities should encapsulate behavior, not just be data bags. Avoid getters/setters without business logic.
Interface Segregation
: Keep repository interfaces small and focused. Do not create god interfaces.
PHP Implementation Constraints
Immutability
: Value Objects MUST be immutable using
readonly
in PHP 8.1+. Never allow mutable state in Value Objects.
Validation
: Validate in Value Objects at construction time. Never allow invalid state to exist.
Symfony Attributes
: Use PHP 8 attributes for routing, validation, and DI (
#[Route]
,
#[Assert\Email]
,
#[Autowire]
).
Testing Constraints
Framework Independence
: Domain and Application layers MUST be testable without Symfony, Doctrine, or database.
Test Doubles
: Always provide In-Memory implementations for repository interfaces to enable fast unit tests.
Warnings
Avoid Rich Domain Models in Controllers
: Controllers should only coordinate, not contain business logic.
Beware of Leaky Abstractions
: Infrastructure concerns (like Doctrine annotations) should not leak into Domain entities. Use XML/YAML mappings instead.
Command Bus Consideration
: For complex applications, use Symfony Messenger for async processing. Do not inline complex orchestrations in handlers.
Domain Events
: Dispatch domain events to decouple side effects from core business logic. Do not call external services directly from entities.
References
PHP Clean Architecture Patterns
Symfony Implementation Guide
?>
← 返回排行榜