Dans le premier article de cette série, on a posé les fondations : pourquoi une architecture propre, les principes SOLID et le choix de l'architecture hexagonale. Aujourd'hui, on entre dans le concret : les 3 couches, la structure de répertoires, et du code pour chacune.
Les 3 couches de l'architecture hexagonale
L'idée centrale est simple : le métier ne dépend de rien. Le framework, la base de données, les APIs externes — tout cela est de l'infrastructure. Le code métier est pur et testable sans aucune dépendance technique.
src/
├── Domain/ # Cœur métier — ZÉRO dépendance framework
│ ├── Entity/ # Entités avec factory methods
│ ├── ValueObject/ # Objets immutables
│ ├── Repository/ # Interfaces uniquement
│ ├── Event/ # Domain events
│ ├── Exception/ # Exceptions métier
│ └── Service/ # Services du domaine
├── Application/ # Cas d'utilisation — dépend du Domain uniquement
│ ├── Command/ # Opérations d'écriture
│ ├── Query/ # Opérations de lecture
│ └── Handler/ # Gestionnaires de commandes/requêtes
├── Infrastructure/ # Implémentations techniques
│ ├── Doctrine/ # Repositories concrets + mapping
│ ├── Symfony/ # Controllers, Forms
│ ├── Security/ # Voters, Authenticators
│ └── Messaging/ # Message handlers
└── Presentation/ # Interface utilisateur
├── Controller/ # Controllers minimalistes
├── DTO/ # Data Transfer Objects
└── Transformer/ # Entity → DTOCouche Domain : la logique métier pure
Le Domain est le cœur de ton application. Il contient les entités, les Value Objects, les interfaces de repository et les events. La règle d'or : aucune importation de Symfony ou Doctrine ici.
<?php
declare(strict_types=1);
namespace App\Domain\ValueObject;
final readonly class Email
{
private function __construct(
private string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Email invalide : {$value}");
}
}
public static function fromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}Chaque Value Object est final readonly, avec un constructeur privé et une factory method. La validation se fait dans le constructeur : un Value Object ne peut jamais exister dans un état invalide.
<?php
declare(strict_types=1);
namespace App\Domain\Entity;
use App\Domain\Event\UserCreated;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
final class User
{
private array $domainEvents = [];
private function __construct(
private readonly UserId $id,
private readonly Email $email,
private \DateTimeImmutable $createdAt,
) {}
public static function create(Email $email): self
{
$user = new self(
id: UserId::generate(),
email: $email,
createdAt: new \DateTimeImmutable(),
);
$user->domainEvents[] = new UserCreated($user->id, $user->email);
return $user;
}
public function id(): UserId { return $this->id; }
public function email(): Email { return $this->email; }
/** @return list<object> */
public function pullDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}Couche Application : les cas d'utilisation
La couche Application orchestre le Domain. Elle ne contient aucune logique métier — seulement la coordination : valider, appeler le Domain, persister, dispatcher les events.
<?php
declare(strict_types=1);
namespace App\Application\Command;
final readonly class CreateUserCommand
{
public function __construct(
public string $email,
) {}
}
// Handler
namespace App\Application\Command\Handler;
use App\Application\Command\CreateUserCommand;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class CreateUserCommandHandler
{
public function __construct(
private UserRepositoryInterface $userRepository,
) {}
public function __invoke(CreateUserCommand $command): void
{
$email = Email::fromString($command->email);
$user = User::create($email);
$this->userRepository->save($user);
}
}Besoin d'un expert Symfony ?
Réserver un appel →Couche Infrastructure : le monde réel
L'Infrastructure implémente les interfaces du Domain. C'est ici que vivent Doctrine, Symfony, les clients HTTP, les services de mail — tout ce qui touche au monde extérieur.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Doctrine\Repository;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\ValueObject\Email;
use App\Domain\ValueObject\UserId;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<User> */
final class UserRepository extends ServiceEntityRepository implements UserRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function save(User $user): void
{
$this->getEntityManager()->persist($user);
$this->getEntityManager()->flush();
}
public function findByEmail(Email $email): ?User
{
return $this->createQueryBuilder('u')
->where('u.email = :email')
->setParameter('email', $email->toString())
->getQuery()
->getOneOrNullResult();
}
}Les Enums PHP : logique métier intégrée
Les enums PHP 8.1+ ne sont pas de simples constantes. Ils portent de la logique métier — transitions d'état, permissions, labels — et appartiennent au Domain :
<?php
declare(strict_types=1);
namespace App\Domain\Enum;
enum OrderStatus: string
{
case PENDING = 'pending';
case CONFIRMED = 'confirmed';
case SHIPPED = 'shipped';
case DELIVERED = 'delivered';
case CANCELLED = 'cancelled';
public function canTransitionTo(self $status): bool
{
return match ($this) {
self::PENDING => in_array($status, [self::CONFIRMED, self::CANCELLED], true),
self::CONFIRMED => in_array($status, [self::SHIPPED, self::CANCELLED], true),
self::SHIPPED => $status === self::DELIVERED,
self::DELIVERED, self::CANCELLED => false,
};
}
public function label(): string
{
return match ($this) {
self::PENDING => 'En attente',
self::CONFIRMED => 'Confirmée',
self::SHIPPED => 'Expédiée',
self::DELIVERED => 'Livrée',
self::CANCELLED => 'Annulée',
};
}
}Règles de dépendance
La règle de dépendance est simple et non négociable :
- Domain → ne dépend de rien
- Application → dépend uniquement du Domain
- Infrastructure → dépend du Domain + frameworks externes
- Presentation → dépend de Application + Infrastructure
Dans le prochain article, on abordera le testing et la qualité : comment tester chaque couche, la structure des tests miroir, le mutation testing avec Infection. Si votre projet Symfony a besoin d'une refonte architecturale, Bear Upgrade est notre offre dédiée à la modernisation de codebases existantes.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.