home / skills / giuseppe-trisciuoglio / developer-kit / clean-architecture

This skill helps you implement Clean Architecture, Hexagonal Architecture and DDD in PHP/Symfony, ensuring testable, framework-agnostic domain logic.

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill clean-architecture

Review the files below or copy the command above to add this skill to your agents.

Files (3)
SKILL.md
16.7 KB
---
name: clean-architecture-php
description: Provides implementation patterns for Clean Architecture, Hexagonal Architecture (Ports & Adapters), and Domain-Driven Design in PHP 8.3+ with Symfony 7.x. Use when architecting enterprise PHP applications with entities/value objects/aggregates, refactoring legacy code to modern patterns, implementing domain-driven design with Symfony, or creating testable backends with clear separation of concerns.
allowed-tools: Read, Write, Bash, Edit, Glob, Grep
category: backend
tags: [clean-architecture, hexagonal-architecture, ddd, domain-driven-design, php, symfony, ports-adapters]
version: 1.0.0
---

# 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:

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
<?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
<?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
<?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
<?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
<?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
<?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
<?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
<?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

```yaml
# 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

```php
<?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 `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](references/php-clean-architecture.md)
- [Symfony Implementation Guide](references/symfony-implementation.md)

Overview

This skill provides practical implementation patterns for Clean Architecture, Hexagonal Architecture (Ports & Adapters), and Domain-Driven Design in PHP 8.3+ with Symfony 7.x. It shows how to structure domain, application, adapter, and infrastructure layers so business logic stays framework-agnostic and testable. Use it to build maintainable enterprise backends, refactor legacy code, or introduce DDD tactics in Symfony projects.

How this skill works

The skill defines a clear layered directory layout and concrete examples for value objects, entities, repository ports, commands, handlers, controllers, and adapters. It explains inward-only dependency rules, how to write immutable value objects, how to model aggregates and domain events, and how to bind ports to adapters via Symfony DI. Examples include in-memory test doubles and Doctrine adapters so you can test core logic without framework dependencies.

When to use it

  • Architecting new enterprise PHP applications with Symfony 7.x
  • Refactoring legacy PHP code to modern, testable patterns
  • Implementing Domain-Driven Design (entities, value objects, aggregates)
  • Designing modular systems with swappable infrastructure adapters
  • Building test suites that run without Symfony/Doctrine

Best practices

  • Follow the dependency rule: dependencies must point inward; domain has no framework references
  • Make value objects immutable (readonly) and validate at construction time
  • Model rich domain entities with behavior and factory methods, avoid anemic models
  • Keep repository interfaces small and focused (Interface Segregation)
  • Provide in-memory adapters for fast unit tests and bind real adapters via DI

Example use cases

  • Create an Email value object with validation and equality checks used across services
  • Implement a User aggregate with domain events and encapsulated business rules
  • Write CreateUserCommand and handler to orchestrate domain creation and persistence
  • Add a Doctrine adapter that implements the UserRepositoryInterface and a matching in-memory test double
  • Expose application handlers through Symfony controllers and request DTOs with validation

FAQ

Can domain code depend on Symfony types?

No. Domain and application layers must remain framework-independent so they are fully testable without Symfony or Doctrine.

How do I test use cases without a database?

Provide in-memory repository adapters implementing repository interfaces and register them in tests so handlers run fast and deterministically.