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.
+-------------------------------------+
| Infrastructure (Frameworks) | Symfony, Doctrine, External APIs
+-------------------------------------+
| Adapter (Interface Adapters) | Controllers, Repositories, Presenters
+-------------------------------------+
| Application (Use Cases) | Commands, Handlers, DTOs
+-------------------------------------+
| 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
<?php
// src/Domain/ValueObject/Email.php
namespace App\Domain\ValueObject;
use InvalidArgumentException;
final readonly class Email
{
public function __construct(
private string $value
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException(
sprintf('"%s" is not a valid email address', $value)
);
}
}
public function value(): string
{
return $this->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
<?php
// src/Domain/Entity/User.php
namespace App\Domain\Entity;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use App\Domain\Event\UserCreatedEvent;
use DateTimeImmutable;
class User
{
private array $domainEvents = [];
public function __construct(
private UserId $id,
private Email $email,
private string $name,
private DateTimeImmutable $createdAt,
private bool $isActive = true
) {
$this->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)
<?php
// src/Domain/Repository/UserRepositoryInterface.php
namespace App\Domain\Repository;
use App\Domain\Entity\User;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
interface UserRepositoryInterface
{
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
public function save(User $user): void;
public function delete(UserId $id): void;
}
Example 4: Command and Handler
<?php
// src/Application/Command/CreateUserCommand.php
namespace App\Application\Command;
final readonly class CreateUserCommand
{
pu