
Clean Architecture Php
- 5 installs
- 318 repo stars
- Updated June 22, 2026
- giuseppe-trisciuoglio/developer-kit-claude-code
Implement Clean Architecture, Hexagonal Architecture, and DDD in PHP 8.3+ with Symfony 7.x using entities, value objects, and ports/adapters.
About
Provides implementation patterns for Clean Architecture, Hexagonal (Ports and Adapters), and Domain-Driven Design in PHP 8.3+ with Symfony 7.x. A developer uses it to architect enterprise PHP apps or refactor legacy code into testable, framework-independent layers.
- Inward-only dependencies with clear separation of concerns
- Entities, value objects, aggregates, and ports/adapters for Symfony
Clean Architecture Php by the numbers
- 5 all-time installs (skills.sh)
- Ranked #53 of 65 PHP & Laravel skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/giuseppe-trisciuoglio/developer-kit-claude-code --skill clean-architecture-phpAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 318 |
| Last updated | June 22, 2026 |
| Repository | giuseppe-trisciuoglio/developer-kit-claude-code ↗ |
What it does
Implement Clean Architecture, Hexagonal Architecture, and DDD in PHP 8.3+ with Symfony 7.x using entities, value objects, and ports/adapters.
Files
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.php3. Implement Domain Layer
Start from the innermost layer (Domain) and work outward:
1. Create Value Objects with validation at construction time - they must be immutable using PHP 8.1+ readonly 2. Create Entities with domain logic and business rules - entities should encapsulate behavior, not just be data bags 3. Define Repository Interfaces (Ports) - keep them small and focused 4. Define Domain Events to decouple side effects from core business logic
4. Implement Application Layer
Build use cases that orchestrate domain objects:
1. Create Commands as readonly DTOs representing write operations 2. Create Queries for read operations (CQRS pattern) 3. Implement Handlers that receive commands/queries and coordinate domain objects 4. Define Service Interfaces for external dependencies (notifications, etc.)
5. Implement Adapter Layer
Create interface adapters that connect Application to Infrastructure:
1. Create Controllers that receive HTTP requests and invoke handlers 2. Create Request DTOs with Symfony validation attributes 3. Implement Repository Adapters that bridge domain interfaces to persistence layer
6. Configure Infrastructure
Set up framework-specific configuration:
1. Configure Symfony DI to bind interfaces to implementations 2. Create test doubles (In-Memory repositories) for unit testing without database 3. 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
{
public function __construct(
public string $id,
public string $email,
public string $name
) {
}
}<?php
// src/Application/Handler/CreateUserHandler.php
namespace App\Application\Handler;
use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use InvalidArgumentException;
readonly class CreateUserHandler
{
public function __construct(
private UserRepositoryInterface $userRepository
) {
}
public function __invoke(CreateUserCommand $command): void
{
$email = new Email($command->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
<?php
// src/Adapter/Http/Controller/UserController.php
namespace App\Adapter\Http\Controller;
use App\Adapter\Http\Request\CreateUserRequest;
use App\Application\Command\CreateUserCommand;
use App\Application\Handler\CreateUserHandler;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[AsController]
class UserController
{
public function __construct(
private CreateUserHandler $createUserHandler
) {
}
#[Route('/api/users', methods: ['POST'])]
public function create(CreateUserRequest $request): JsonResponse
{
$command = new CreateUserCommand(
id: Uuid::v4()->toRfc4122(),
email: $request->email,
name: $request->name
);
($this->createUserHandler)($command);
return new JsonResponse(['id' => $command->id], 201);
}
}Example 6: Request DTO with Validation
<?php
// src/Adapter/Http/Request/CreateUserRequest.php
namespace App\Adapter\Http\Request;
use Symfony\Component\Validator\Constraints as Assert;
class CreateUserRequest
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name;
}Example 7: Doctrine Repository Adapter
<?php
// src/Adapter/Persistence/Doctrine/Repository/DoctrineUserRepository.php
namespace App\Adapter\Persistence\Doctrine\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Doctrine\ORM\EntityManagerInterface;
readonly class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {
}
public function findById(UserId $id): ?User
{
return $this->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\InMemoryUserRepositoryExample 9: In-Memory Repository for Testing
<?php
// tests/Infrastructure/Repository/InMemoryUserRepository.php
namespace App\Tests\Infrastructure\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
class InMemoryUserRepository implements UserRepositoryInterface
{
private array $users = [];
public function findById(UserId $id): ?User
{
return $this->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
1. Dependency Rule: Dependencies only point inward - domain knows nothing of application or infrastructure 2. Immutability: Value Objects MUST be immutable using readonly in PHP 8.1+ - never allow mutable state 3. Rich Domain Models: Put business logic in entities with factory methods like create() - avoid anemic models 4. Interface Segregation: Keep repository interfaces small and focused - do not create god interfaces 5. Framework Independence: Domain and application layers MUST be testable without Symfony or Doctrine 6. Validation at Construction: Validate in Value Objects at construction time - never allow invalid state 7. Symfony Attributes: Use PHP 8 attributes for routing (#[Route]), validation (#[Assert\]), and DI 8. Test Doubles: Always provide In-Memory implementations for repositories to enable fast unit tests 9. Domain Events: Dispatch domain events to decouple side effects - do not call external services from entities 10. 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
readonlyin 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
PHP Clean Architecture Patterns
Value Objects Deep Dive
Money Value Object
<?php
// src/Domain/ValueObject/Money.php
namespace App\Domain\ValueObject;
use InvalidArgumentException;
final readonly class Money
{
public function __construct(
private int $cents,
private string $currency
) {
if ($cents < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
if (!in_array($currency, ['EUR', 'USD', 'GBP'], true)) {
throw new InvalidArgumentException('Unsupported currency');
}
}
public static function fromEuros(float $amount): self
{
return new self((int) round($amount * 100), 'EUR');
}
public function cents(): int
{
return $this->cents;
}
public function asFloat(): float
{
return $this->cents / 100;
}
public function currency(): string
{
return $this->currency;
}
public function add(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->cents + $other->cents, $this->currency);
}
public function subtract(self $other): self
{
$this->assertSameCurrency($other);
return new self($this->cents - $other->cents, $this->currency);
}
public function multiply(float $factor): self
{
return new self((int) round($this->cents * $factor), $this->currency);
}
public function equals(self $other): bool
{
return $this->cents === $other->cents
&& $this->currency === $other->currency;
}
public function isGreaterThan(self $other): bool
{
$this->assertSameCurrency($other);
return $this->cents > $other->cents;
}
private function assertSameCurrency(self $other): void
{
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
}
}UUID Value Object
<?php
// src/Domain/ValueObject/UserId.php
namespace App\Domain\ValueObject;
use InvalidArgumentException;
use Symfony\Component\Uid\Uuid;
final readonly class UserId
{
private string $value;
public function __construct(string $value)
{
if (!Uuid::isValid($value)) {
throw new InvalidArgumentException('Invalid UUID format');
}
$this->value = $value;
}
public static function generate(): self
{
return new self(Uuid::v4()->toRfc4122());
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}Aggregate Pattern
<?php
// src/Domain/Entity/Order.php
namespace App\Domain\Entity;
use App\Domain\ValueObject\Money;
use App\Domain\ValueObject\OrderId;
use App\Domain\Event\OrderSubmittedEvent;
use InvalidArgumentException;
class Order
{
private array $items = [];
private string $status = 'pending';
private array $domainEvents = [];
public function __construct(
private OrderId $id,
private UserId $userId
) {
}
public function addItem(string $productId, int $quantity, Money $price): void
{
if ($this->status !== 'pending') {
throw new InvalidArgumentException('Cannot modify submitted order');
}
if ($quantity <= 0) {
throw new InvalidArgumentException('Quantity must be positive');
}
$this->items[] = new OrderItem($productId, $quantity, $price);
}
public function submit(): void
{
if (empty($this->items)) {
throw new InvalidArgumentException('Cannot submit empty order');
}
if ($this->status !== 'pending') {
throw new InvalidArgumentException('Order already submitted');
}
$this->status = 'submitted';
$this->recordEvent(new OrderSubmittedEvent($this->id->value()));
}
public function total(): Money
{
$total = Money::fromEuros(0);
foreach ($this->items as $item) {
$total = $total->add($item->total());
}
return $total;
}
public function id(): OrderId
{
return $this->id;
}
public function domainEvents(): array
{
return $this->domainEvents;
}
public function clearDomainEvents(): void
{
$this->domainEvents = [];
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
}OrderItem (Part of Aggregate)
<?php
// src/Domain/Entity/OrderItem.php
namespace App\Domain\Entity;
use App\Domain\ValueObject\Money;
final readonly class OrderItem
{
public function __construct(
private string $productId,
private int $quantity,
private Money $unitPrice
) {
}
public function total(): Money
{
return $this->unitPrice->multiply($this->quantity);
}
}Domain Services
When logic does not belong to a single entity:
<?php
// src/Domain/Service/PricingService.php
namespace App\Domain\Service;
use App\Domain\Entity\Order;
use App\Domain\ValueObject\Money;
interface PricingServiceInterface
{
public function calculateDiscount(Order $order): Money;
}Domain Events
<?php
// src/Domain/Event/OrderSubmittedEvent.php
namespace App\Domain\Event;
use DateTimeImmutable;
final readonly class OrderSubmittedEvent
{
public function __construct(
public string $orderId,
public DateTimeImmutable $occurredAt = new DateTimeImmutable()
) {
}
}Specification Pattern
<?php
// src/Domain/Specification/SpecificationInterface.php
namespace App\Domain\Specification;
interface SpecificationInterface
{
public function isSatisfiedBy(object $candidate): bool;
}<?php
// src/Domain/Specification/ActiveUserSpecification.php
namespace App\Domain\Specification;
use App\Domain\Entity\User;
class ActiveUserSpecification implements SpecificationInterface
{
public function isSatisfiedBy(object $candidate): bool
{
if (!$candidate instanceof User) {
return false;
}
return $candidate->isActive();
}
}Enum for Status (PHP 8.1+)
<?php
// src/Domain/Enum/OrderStatus.php
namespace App\Domain\Enum;
enum OrderStatus: string
{
case PENDING = 'pending';
case SUBMITTED = 'submitted';
case PAID = 'paid';
case SHIPPED = 'shipped';
case CANCELLED = 'cancelled';
public function canTransitionTo(self $newStatus): bool
{
return match ($this) {
self::PENDING => in_array($newStatus, [self::SUBMITTED, self::CANCELLED], true),
self::SUBMITTED => in_array($newStatus, [self::PAID, self::CANCELLED], true),
self::PAID => in_array($newStatus, [self::SHIPPED, self::CANCELLED], true),
self::SHIPPED => false,
self::CANCELLED => false,
};
}
}Testing Value Objects
<?php
// tests/Domain/ValueObject/EmailTest.php
namespace App\Tests\Domain\ValueObject;
use App\Domain\ValueObject\Email;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class EmailTest extends TestCase
{
public function testCanCreateValidEmail(): void
{
$email = new Email('test@example.com');
$this->assertEquals('test@example.com', $email->value());
}
public function testThrowsExceptionForInvalidEmail(): void
{
$this->expectException(InvalidArgumentException::class);
new Email('invalid-email');
}
public function testEmailsAreComparable(): void
{
$email1 = new Email('test@example.com');
$email2 = new Email('test@example.com');
$email3 = new Email('other@example.com');
$this->assertTrue($email1->equals($email2));
$this->assertFalse($email1->equals($email3));
}
}Testing Use Cases (No Framework)
<?php
// tests/Application/Handler/CreateUserHandlerTest.php
namespace App\Tests\Application\Handler;
use App\Application\Command\CreateUserCommand;
use App\Application\Handler\CreateUserHandler;
use App\Tests\Infrastructure\Repository\InMemoryUserRepository;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;
class CreateUserHandlerTest extends TestCase
{
private InMemoryUserRepository $repository;
private CreateUserHandler $handler;
protected function setUp(): void
{
$this->repository = new InMemoryUserRepository();
$this->handler = new CreateUserHandler($this->repository);
}
public function testCanCreateUser(): void
{
$command = new CreateUserCommand(
id: '550e8400-e29b-41d4-a716-446655440000',
email: 'test@example.com',
name: 'John Doe'
);
($this->handler)($command);
$user = $this->repository->findById(new UserId($command->id));
$this->assertNotNull($user);
$this->assertEquals('John Doe', $user->name());
}
public function testCannotCreateDuplicateUser(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('User with this email already exists');
$command = new CreateUserCommand(
id: '550e8400-e29b-41d4-a716-446655440000',
email: 'test@example.com',
name: 'John Doe'
);
($this->handler)($command);
($this->handler)($command);
}
}Symfony Implementation Guide
Project Setup
Directory Structure
project/
├── config/
│ ├── packages/
│ │ ├── doctrine.yaml
│ │ └── messenger.yaml
│ └── services.yaml
├── src/
│ ├── Domain/
│ ├── Application/
│ ├── Adapter/
│ ├── Infrastructure/
│ └── Kernel.php
├── tests/
│ ├── Unit/
│ ├── Integration/
│ └── Infrastructure/
└── composer.jsonComposer Dependencies
{
"require": {
"php": ">=8.3",
"symfony/framework-bundle": "^7.0",
"symfony/dependency-injection": "^7.0",
"symfony/messenger": "^7.0",
"symfony/serializer": "^7.0",
"symfony/property-access": "^7.0",
"symfony/uid": "^7.0",
"doctrine/orm": "^3.0",
"doctrine/dbal": "^4.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"symfony/test-pack": "^1.0"
}
}Dependency Injection Configuration
Autowiring Repository Interface
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
exclude:
- '../src/Domain/Entity/'
- '../src/Kernel.php'
# Bind interface to implementation
App\Domain\Repository\UserRepositoryInterface:
class: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository
App\Domain\Repository\OrderRepositoryInterface:
class: App\Adapter\Persistence\Doctrine\Repository\DoctrineOrderRepositoryMultiple Implementations with Named Services
# config/services.yaml
services:
# Primary: Doctrine implementation
App\Domain\Repository\UserRepositoryInterface:
alias: App\Adapter\Persistence\Doctrine\Repository\DoctrineUserRepository
# Alternative: In-memory for testing
App\Infrastructure\Repository\InMemoryUserRepository:
class: App\Infrastructure\Repository\InMemoryUserRepository
# Use named autowiring
App\Application\Handler\CreateUserHandler:
arguments:
$userRepository: '@App\Domain\Repository\UserRepositoryInterface'#[Autowire] Attribute (PHP 8)
<?php
// src/Infrastructure/Service/SendgridEmailService.php
namespace App\Infrastructure\Service;
use App\Application\Service\EmailServiceInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
readonly class SendgridEmailService implements EmailServiceInterface
{
public function __construct(
#[Autowire('%env(SENDGRID_API_KEY)%')]
private string $apiKey,
#[Autowire('%env(SENDGRID_FROM_EMAIL)%')]
private string $fromEmail
) {
}
public function send(string $to, string $subject, string $body): void
{
// Sendgrid implementation
}
}Doctrine ORM Mapping
XML Mapping (Recommended)
<!-- config/doctrine/User.orm.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping"
xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://doctrine-project.org/schemas/orm/doctrine-mapping
https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
<entity name="App\Domain\Entity\User" table="users">
<id name="id" type="string" length="36">
<generator strategy="NONE"/>
</id>
<embedded name="email" class="App\Domain\ValueObject\Email"/>
<field name="name" type="string" length="100"/>
<field name="createdAt" type="datetime_immutable"/>
<field name="isActive" type="boolean"/>
</entity>
</doctrine-mapping>Value Object Embeddable
<!-- config/doctrine/Email.orm.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<doctrine-mapping xmlns="https://doctrine-project.org/schemas/orm/doctrine-mapping">
<embeddable name="App\Domain\ValueObject\Email">
<field name="value" type="string" column="email" length="255"/>
</embeddable>
</doctrine-mapping>Attribute Mapping (Alternative)
<?php
// src/Domain/Entity/User.php
namespace App\Domain\Entity;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User
{
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
private string $id;
#[ORM\Embedded(class: Email::class)]
private Email $email;
#[ORM\Column(type: 'string', length: 100)]
private string $name;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'boolean')]
private bool $isActive = true;
// ... methods
}Controllers with Attributes
REST API Controller
<?php
// src/Adapter/Http/Controller/Api/UserController.php
namespace App\Adapter\Http\Controller\Api;
use App\Adapter\Http\Request\CreateUserRequest;
use App\Adapter\Http\Request\UpdateUserRequest;
use App\Application\Command\CreateUserCommand;
use App\Application\Command\UpdateUserCommand;
use App\Application\Handler\CreateUserHandler;
use App\Application\Handler\UpdateUserHandler;
use App\Application\Query\GetUserQuery;
use App\Domain\ValueObject\UserId;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Uid\Uuid;
#[AsController]
#[Route('/api/users')]
class UserController
{
public function __construct(
private CreateUserHandler $createHandler,
private UpdateUserHandler $updateHandler,
private GetUserQuery $getUserQuery
) {
}
#[Route('', methods: ['POST'])]
public function create(
#[MapRequestPayload] CreateUserRequest $request
): JsonResponse {
$id = Uuid::v4()->toRfc4122();
$command = new CreateUserCommand(
id: $id,
email: $request->email,
name: $request->name
);
($this->createHandler)($command);
return new JsonResponse(
['id' => $id, 'message' => 'User created'],
Response::HTTP_CREATED
);
}
#[Route('/{id}', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$user = $this->getUserQuery->execute(new UserId($id));
if ($user === null) {
return new JsonResponse(
['error' => 'User not found'],
Response::HTTP_NOT_FOUND
);
}
return new JsonResponse($user);
}
#[Route('/{id}', methods: ['PUT'])]
public function update(
string $id,
#[MapRequestPayload] UpdateUserRequest $request
): JsonResponse {
$command = new UpdateUserCommand(
id: $id,
name: $request->name
);
($this->updateHandler)($command);
return new JsonResponse(['message' => 'User updated']);
}
}Request Validation with Attributes
<?php
// src/Adapter/Http/Request/CreateUserRequest.php
namespace App\Adapter\Http\Request;
use Symfony\Component\Validator\Constraints as Assert;
class CreateUserRequest
{
#[Assert\NotBlank(message: 'Email is required')]
#[Assert\Email(message: 'Invalid email format')]
public string $email;
#[Assert\NotBlank(message: 'Name is required')]
#[Assert\Length(
min: 2,
max: 100,
minMessage: 'Name must be at least {{ limit }} characters',
maxMessage: 'Name cannot exceed {{ limit }} characters'
)]
public string $name;
}Symfony Messenger for Commands
Command Bus Configuration
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction_middleware
transports:
async: '%env(MESSENGER_TRANSPORT_DSN)%'
routing:
App\Application\Command\SendNotificationCommand: asyncMessage Handler
<?php
// src/Application/Handler/CreateUserHandler.php
namespace App\Application\Handler;
use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
readonly class CreateUserHandler
{
public function __construct(
private UserRepositoryInterface $userRepository
) {
}
public function __invoke(CreateUserCommand $command): void
{
$user = User::create(
new UserId($command->id),
new Email($command->email),
$command->name
);
$this->userRepository->save($user);
}
}Dispatching Commands
<?php
// src/Adapter/Http/Controller/UserController.php
use Symfony\Component\Messenger\MessageBusInterface;
class UserController
{
public function __construct(
private MessageBusInterface $commandBus
) {
}
#[Route('/api/users', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
$command = new CreateUserCommand(
id: Uuid::v4()->toRfc4122(),
email: $data['email'],
name: $data['name']
);
$this->commandBus->dispatch($command);
return new JsonResponse(['id' => $command->id], 201);
}
}Domain Events with Symfony EventDispatcher
Domain Event Listener
<?php
// src/Infrastructure/Event/DomainEventDispatcher.php
namespace App\Infrastructure\Event;
use App\Domain\Event\UserCreatedEvent;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: UserCreatedEvent::class, method: 'onUserCreated')]
readonly class UserCreatedListener
{
public function __construct(
private LoggerInterface $logger,
private EmailServiceInterface $emailService
) {
}
public function onUserCreated(UserCreatedEvent $event): void
{
$this->logger->info('User created', ['user_id' => $event->userId]);
// Send welcome email
$this->emailService->sendWelcomeEmail($event->userId);
}
}Publishing Domain Events
<?php
// src/Adapter/Persistence/Doctrine/Repository/DoctrineUserRepository.php
namespace App\Adapter\Persistence\Doctrine\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
readonly class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private EventDispatcherInterface $eventDispatcher
) {
}
public function save(User $user): void
{
$this->entityManager->persist($user);
$this->entityManager->flush();
// Dispatch domain events
foreach ($user->domainEvents() as $event) {
$this->eventDispatcher->dispatch($event);
}
$user->clearDomainEvents();
}
}Testing with Symfony
Integration Test
<?php
// tests/Integration/Controller/UserControllerTest.php
namespace App\Tests\Integration\Controller;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
class UserControllerTest extends WebTestCase
{
public function testCanCreateUser(): void
{
$client = static::createClient();
$client->request(
'POST',
'/api/users',
[],
[],
['CONTENT_TYPE' => 'application/json'],
json_encode([
'email' => 'test@example.com',
'name' => 'John Doe'
])
);
$this->assertResponseStatusCodeSame(201);
$response = json_decode($client->getResponse()->getContent(), true);
$this->assertArrayHasKey('id', $response);
}
public function testReturns404ForNonExistentUser(): void
{
$client = static::createClient();
$client->request('GET', '/api/users/non-existent-id');
$this->assertResponseStatusCodeSame(404);
}
}Repository Integration Test
<?php
// tests/Integration/Repository/UserRepositoryTest.php
namespace App\Tests\Integration\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class UserRepositoryTest extends KernelTestCase
{
private UserRepositoryInterface $repository;
protected function setUp(): void
{
self::bootKernel();
$this->repository = self::getContainer()->get(UserRepositoryInterface::class);
}
public function testCanSaveAndRetrieveUser(): void
{
$user = User::create(
UserId::generate(),
new Email('test@example.com'),
'John Doe'
);
$this->repository->save($user);
$retrieved = $this->repository->findById($user->id());
$this->assertNotNull($retrieved);
$this->assertTrue($user->email()->equals($retrieved->email()));
}
}Error Handling
Domain Exception Handler
<?php
// src/Infrastructure/Http/Exception/DomainExceptionHandler.php
namespace App\Infrastructure\Http\Exception;
use InvalidArgumentException;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class DomainExceptionHandler
{
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if ($exception instanceof InvalidArgumentException) {
$response = new JsonResponse([
'error' => 'Validation failed',
'message' => $exception->getMessage()
], Response::HTTP_BAD_REQUEST);
$event->setResponse($response);
return;
}
// Handle other domain exceptions
if ($exception instanceof DomainException) {
$response = new JsonResponse([
'error' => 'Domain error',
'message' => $exception->getMessage()
], Response::HTTP_UNPROCESSABLE_ENTITY);
$event->setResponse($response);
}
}
}Registering Exception Handler
# config/services.yaml
services:
App\Infrastructure\Http\Exception\DomainExceptionHandler:
tags:
- { name: kernel.event_listener, event: kernel.exception }Query Bus (CQRS)
Query Interface
<?php
// src/Application/Query/QueryInterface.php
namespace App\Application\Query;
interface QueryInterface
{
}Query Handler
<?php
// src/Application/Query/GetUserQuery.php
namespace App\Application\Query;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\UserId;
readonly class GetUserQuery
{
public function __construct(
private UserRepositoryInterface $userRepository
) {
}
public function execute(UserId $id): ?array
{
$user = $this->userRepository->findById($id);
if ($user === null) {
return null;
}
return [
'id' => $user->id()->value(),
'email' => $user->email()->value(),
'name' => $user->name(),
'createdAt' => $user->createdAt()->format('c'),
'isActive' => $user->isActive()
];
}
}Best Practices for Symfony
1. Use Attributes: Leverage PHP 8 attributes for routing, validation, DI, and event listening.
2. Keep Controllers Thin: Controllers should only handle HTTP concerns and delegate to application layer.
3. Use DTOs: Separate request/response DTOs from domain entities.
4. Validation at Boundaries: Validate input at the adapter layer (controllers), not in domain.
5. Messenger for Async: Use Symfony Messenger for commands that need async processing.
6. XML/Attribute Mapping: Prefer XML or PHP 8 attributes for Doctrine mapping over annotations.
7. Service Autoconfiguration: Let Symfony autoconfigure your services, only override when necessary.
8. Test with Real Container: Use KernelTestCase for integration tests with real DI container.
9. Environment Variables: Use %env()% for configuration that changes per environment.
10. Monolog for Logging: Use PSR-3 logger interface in domain, Monolog implementation in infrastructure.