Blog / · 9 min de lecture
Clean Architecture avec Symfony : garder Doctrine, Messenger et le container à leur place
Symfony pousse déjà vers des services propres, mais ça ne suffit pas. Voici comment isoler le domaine, structurer les use cases, brancher Doctrine et Messenger, et savoir quand cette architecture vaut vraiment le coût.
Symfony donne une meilleure discipline que beaucoup de frameworks.
Le container est explicite, l’autowiring pousse vers l’injection de dépendances, les controllers peuvent rester fins, Doctrine sépare déjà les repositories, Messenger donne un bus de messages. On peut donc croire que la Clean Architecture est presque automatique.
Elle ne l’est pas.
Un projet Symfony peut très vite finir avec des entités Doctrine qui portent trop de métier, des services Manager qui orchestrent tout, des subscribers cachés, et des handlers Messenger qui deviennent des mini-applications. Le framework est propre. Ton architecture peut quand même devenir floue.
La Clean Architecture avec Symfony consiste à décider volontairement où vit le métier, où vivent les cas d’usage, et où le framework a le droit d’entrer.
Symfony n’est pas ton domaine
Une entité Doctrine ressemble souvent à une entité métier. C’est là que le piège commence.
Doctrine veut hydrater, suivre les changements, gérer les proxies, mapper les relations. Ton domaine veut protéger des invariants métier.
Ces deux rôles peuvent cohabiter sur les petits modules. Mais dès que les règles deviennent sérieuses, mélanger les deux coûte cher :
- tests qui bootent le kernel ou une base pour vérifier une règle simple;
- setters ajoutés pour Doctrine mais dangereux métier;
- lazy loading déclenché dans une boucle;
- invariants contournés par hydratation ou fixtures;
- services applicatifs qui savent trop de détails de persistance.
La première décision est donc de choisir module par module : entité Doctrine comme modèle métier léger, ou domaine pur séparé.
Structure pragmatique
Un découpage efficace dans Symfony :
src/
Billing/
Domain/
Invoice.php
InvoiceId.php
InvoiceStatus.php
Exception/
InvalidInvoiceAmount.php
InvoiceCannotBePaid.php
Application/
PayInvoice/
PayInvoiceCommand.php
PayInvoiceHandler.php
Port/
InvoiceRepository.php
PaymentGateway.php
Infrastructure/
Doctrine/
DoctrineInvoiceRepository.php
InvoiceEntity.php
InvoiceMapper.php
Payment/
StripePaymentGateway.php
UserInterface/
Http/
PayInvoiceController.php
Tu peux garder src/Controller, src/Entity, src/Repository sur un projet simple. Mais pour un bounded context dense, le groupement par module rend les dépendances plus lisibles.
Domaine : pas de Doctrine, pas de Validator Symfony
Le domaine exprime les règles. Il ne dépend ni de EntityManagerInterface, ni de Constraint, ni de Request, ni de MessageBusInterface.
<?php
namespace App\Billing\Domain;
use App\Billing\Domain\Exception\InvalidInvoiceAmount;
use App\Billing\Domain\Exception\InvoiceCannotBePaid;
final class Invoice
{
private function __construct(
private readonly InvoiceId $id,
private InvoiceStatus $status,
private Money $amount,
) {}
public static function issue(InvoiceId $id, Money $amount): self
{
if ($amount->isZeroOrNegative()) {
throw new InvalidInvoiceAmount();
}
return new self($id, InvoiceStatus::Issued, $amount);
}
public static function reconstitute(
InvoiceId $id,
InvoiceStatus $status,
Money $amount,
): self {
return new self($id, $status, $amount);
}
public function markAsPaid(): void
{
if ($this->status !== InvoiceStatus::Issued) {
throw new InvoiceCannotBePaid($this->id, $this->status);
}
$this->status = InvoiceStatus::Paid;
}
public function id(): InvoiceId
{
return $this->id;
}
}
Ce code se teste sans Symfony. C’est volontaire. Le domaine ne doit pas avoir besoin du container pour exister.
Application : use cases et ports
La couche application orchestre un scénario métier. Avec Symfony, tu peux l’implémenter en handler Messenger ou en service callable. Les deux marchent.
<?php
namespace App\Billing\Application\PayInvoice;
final readonly class PayInvoiceCommand
{
public function __construct(
public string $invoiceId,
public string $paymentReference,
) {}
}
<?php
namespace App\Billing\Application\PayInvoice;
use App\Billing\Application\Port\InvoiceRepository;
use App\Billing\Application\Port\PaymentGateway;
use App\Billing\Domain\InvoiceId;
final readonly class PayInvoiceHandler
{
public function __construct(
private InvoiceRepository $invoices,
private PaymentGateway $payments,
) {}
public function __invoke(PayInvoiceCommand $command): void
{
$invoice = $this->invoices->get(new InvoiceId($command->invoiceId));
$this->payments->capture($invoice, $command->paymentReference);
$invoice->markAsPaid();
$this->invoices->save($invoice);
}
}
Ce snippet compacte volontairement la capture paiement et le changement d’état dans le même handler pour montrer l’orchestration. Si ce handler est branché sur un command.bus avec doctrine_transaction, ne garde pas l’appel réseau tel quel : sors-le via outbox, message après commit, ou étape idempotente séparée.
Le handler ne connaît pas Doctrine. Il connaît un port.
<?php
namespace App\Billing\Application\Port;
use App\Billing\Domain\Invoice;
use App\Billing\Domain\InvoiceId;
interface InvoiceRepository
{
public function get(InvoiceId $id): Invoice;
public function save(Invoice $invoice): void;
}
Cette interface n’existe pas pour faire joli. Elle existe parce que le cas d’usage ne doit pas dépendre de la manière dont la facture est chargée ou sauvegardée.
Infrastructure : Doctrine comme adaptateur
Doctrine peut rester excellent. Mais il devient un adaptateur.
<?php
namespace App\Billing\Infrastructure\Doctrine;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'invoices')]
class InvoiceEntity
{
#[ORM\Id]
#[ORM\Column(type: 'string')]
public string $id;
#[ORM\Column(type: 'string')]
public string $status;
#[ORM\Column(type: 'integer')]
public int $amountInCents;
#[ORM\Column(type: 'string')]
public string $currency;
}
Repository :
<?php
namespace App\Billing\Infrastructure\Doctrine;
use App\Billing\Application\Port\InvoiceRepository;
use App\Billing\Domain\Invoice;
use App\Billing\Domain\InvoiceId;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineInvoiceRepository implements InvoiceRepository
{
public function __construct(private EntityManagerInterface $entityManager) {}
public function get(InvoiceId $id): Invoice
{
$entity = $this->entityManager->find(InvoiceEntity::class, $id->toString());
if (!$entity instanceof InvoiceEntity) {
throw new InvoiceNotFound($id);
}
return InvoiceMapper::toDomain($entity);
}
public function save(Invoice $invoice): void
{
$entity = $this->entityManager->find(
InvoiceEntity::class,
$invoice->id()->toString(),
) ?? new InvoiceEntity();
InvoiceMapper::fillEntity($entity, $invoice);
$this->entityManager->persist($entity);
$this->entityManager->flush();
}
}
Oui, le mapper est une friction. Mais il rend le choix explicite : l’entité Doctrine sert la persistance, l’objet de domaine sert le métier.
Deux détails comptent : InvoiceMapper::toDomain() doit passer par Invoice::reconstitute() pour reconstruire une facture déjà payée, et fillEntity() doit mettre à jour l’entité managée existante au lieu de créer une nouvelle entité avec la même clé primaire.
Si tu utilises déjà doctrine_transaction au niveau Messenger, le repository peut aussi se limiter à persist() et laisser le middleware flusher en fin de command. Le snippet garde flush() pour rester autonome hors bus, mais il faut choisir une seule politique par projet.
Controller : Request vers Command
Le controller reste un adaptateur d’entrée.
<?php
namespace App\Billing\UserInterface\Http;
use App\Billing\Application\PayInvoice\PayInvoiceCommand;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final readonly class PayInvoiceController
{
public function __construct(private MessageBusInterface $commandBus) {}
#[Route('/invoices/{invoiceId}/pay', methods: ['POST'])]
public function __invoke(string $invoiceId, Request $request): JsonResponse
{
$payload = $request->toArray();
$this->commandBus->dispatch(new PayInvoiceCommand(
invoiceId: $invoiceId,
paymentReference: (string) $payload['payment_reference'],
));
return new JsonResponse(null, 202);
}
}
Dans un vrai projet, tu peux ajouter un DTO d’entrée, le Validator Symfony, ou un resolver. Ce qui compte : la validation d’entrée reste au bord, la règle métier reste dans le domaine.
Messenger : utile, mais pas obligatoire
Messenger est naturel pour une architecture par cas d’usage. Mais attention : tous les use cases n’ont pas besoin d’un bus.
Configuration simple :
# config/packages/messenger.yaml
framework:
messenger:
default_bus: command.bus
buses:
command.bus:
middleware:
- doctrine_transaction
query.bus: ~
event.bus:
default_middleware: allow_no_handlers
Le command.bus peut porter une transaction Doctrine. Le query.bus n’en a généralement pas besoin. L’event.bus sert aux réactions.
Le piège classique : multiplier les bus sans différence de comportement. Si deux bus ont la même stack middleware et les mêmes contraintes, tu as peut-être seulement ajouté du vocabulaire.
Autre piège : mettre un appel réseau dans un handler couvert par doctrine_transaction. Capturer un paiement ou réserver du stock pendant qu’une transaction SQL reste ouverte crée des verrous longs, et l’effet externe ne sera pas rollbacké si le flush échoue. Pour les side effects critiques, préfère un outbox, un message afterCommit, ou une étape idempotente séparée.
Validation : trois niveaux
Symfony Validator est très bon, mais il ne remplace pas le domaine.
Sépare :
- validation syntaxique : payload HTTP, types, champs requis;
- validation applicative : droits, existence d’une ressource, cohérence de commande;
- invariant métier : règle qui doit rester vraie partout.
Exemple :
- “payment_reference est requis” : bord HTTP;
- “la facture existe” : application/repository;
- “une facture déjà payée ne peut pas être repayée” : domaine.
Si tu mets tout en contraintes Symfony sur une entité Doctrine, tu rends la règle dépendante d’un contexte de validation. Pour certains modules, c’est suffisant. Pour un coeur métier, c’est fragile.
Tests : trois vitesses
Test de domaine :
public function test_paid_invoice_cannot_be_paid_again(): void
{
$invoice = InvoiceBuilder::paid();
$this->expectException(InvoiceCannotBePaid::class);
$invoice->markAsPaid();
}
Test de handler :
public function test_pay_invoice_captures_payment_and_saves_invoice(): void
{
$invoices = new InMemoryInvoiceRepository([
InvoiceBuilder::issued(id: 'inv_123'),
]);
$payments = new FakePaymentGateway();
$handler = new PayInvoiceHandler($invoices, $payments);
$handler(new PayInvoiceCommand('inv_123', 'pay_456'));
self::assertTrue($payments->captured('pay_456'));
self::assertTrue($invoices->get(new InvoiceId('inv_123'))->isPaid());
}
Test Symfony :
public function test_pay_invoice_endpoint_returns_accepted(): void
{
static::createClient()->request(
method: 'POST',
uri: '/invoices/inv_123/pay',
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['payment_reference' => 'pay_456'], JSON_THROW_ON_ERROR),
);
self::assertResponseStatusCodeSame(202);
}
Le premier test protège l’invariant. Le deuxième protège le scénario. Le troisième protège le câblage HTTP. Si tous tes tests sont du troisième type, ton architecture est trop couplée au framework.
Migration depuis un Symfony classique
Ne commence pas par déplacer tous les dossiers.
Commence par un module :
- choisis un cas d’usage douloureux;
- écris un handler ou une action dédiée;
- isole l’entrée dans une command;
- crée un port uniquement pour les dépendances qui gênent le test;
- garde Doctrine direct si le mapping séparé ne vaut pas encore le coût;
- déplace les invariants dans un objet métier pur quand ils deviennent difficiles à tester.
Tu peux avoir un projet hybride : CRUD classique sur 80% de l’app, Clean Architecture sur facturation, stock, paiements, disponibilité ou tarification.
Ce n’est pas un échec. C’est souvent le bon design.
Quand Symfony classique suffit
Reste simple si :
- le module est un CRUD administratif;
- les règles sont stables et peu nombreuses;
- Doctrine Entity + Repository donne des tests acceptables;
- l’équipe serait ralentie par trop de mapping;
- le produit n’a pas encore prouvé son domaine.
Renforce l’architecture si :
- les règles métier changent toutes les semaines;
- plusieurs interfaces déclenchent le même use case : HTTP, CLI, message async, webhook;
- les tests sont lents ou fragiles;
- les entités Doctrine ont trop de méthodes sans rapport avec la persistance;
- les side effects doivent être contrôlés : paiement, email, stock, documents légaux.
La Clean Architecture n’est pas “plus Symfony”. C’est moins de dépendance au framework là où le métier doit durer.
Sources
Continuer la lecture