Aller au contenu principal
Retour au blog

Architecture Symfony de A à Z — Partie 2 : Couches et responsabilités

Flavien Métivier28 octobre 20258 min

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 → DTO

Couche 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.

Cet article vous a plu ? Partagez-le !

Besoin d'un expert Symfony ?

20 ans d'expérience sur l'écosystème PHP/Symfony.