Se rendre au contenu

La validation avec Symfony dans la Clean Architecture

Le pragmatisme de la validation en clean architecture
1 août 2026 par
LIONEL KOUAME

S'il y a bien un sujet qui a suscité des débats enflammés au sein de mes équipes, c'est celui-ci : où doit réellement résider la validation des données d'entrée dans une Clean Architecture ?

En fonction des appétences de chacun, des cas d'utilisation ou des contraintes du projet, plusieurs visions s’opposent, avec des arguments très pertinents de part et d’autre. Entre les défenseurs de la pureté absolue du Domaine et ceux qui privilégient la Developer Experience (DX), le dilemme est permanent.

Aujourd'hui, je vous propose d'explorer ce sujet à travers un cas concret : une application  PHP 8.4 / Symfony 8.1 exposée avec API Platform 4.

Dans cette stack, la question de la Developer Experience est encore plus prégnante, dans la mesure où Symfony nous offre des outils puissants pour simplifier la validation.

Pourquoi réimplémenter soi-même la validation d’un IBAN alors que Symfony propose de le faire avec un simple attribut `#[Assert\Iban]` ?

Face à cette question, deux visions s'affrontent régulièrement en Code Review :

La vision "Developer Experience" (DX & Pragmatique)Pour les partisans de cette approche, la réponse est évidente : pourquoi réinventer la roue ?

En ajoutant l'attribut `#[Assert\Iban]` sur un DTO d'entrée, Symfony s'occupe de tout. Mieux encore : grâce à API Platform 4, la moindre erreur de saisie est interceptée immédiatement et déclenche une réponse HTTP `422 Unprocessable Entity` parfaitement formatée en *Problem Details* pour le client API, sans écrire la moindre ligne de code supplémentaire. C'est rapide, propre et standardisé dans l’optique de respecter les standards du Web.

La vision "Pureté du Domaine" (DDD & Architecture Stricte)

Pour les défenseurs du Domain-Driven Design, cette facilité est un piège. Si votre règle de validation ne vit que via un attribut Symfony sur un DTO HTTP, que se passe-t-il si votre Use Case est exécuté depuis une commande CLI, un consommateur de message RabbitMQ ou un test unitaire ?

L'enregistrement d'un IBAN invalide devient possible ! Pour cette école, un objet du Domaine (comme un Value Object `Iban`) doit être son propre gardien : il ne doit jamais pouvoir exister dans un état invalide, quel que soit le canal d'entrée.

Alors, comment trancher sans sacrifier la productivité de l'équipe ni la sécurité de l'application ?

Pour bien comprendre les enjeux, analysons d'abord l'approche que nous avons tous implémentée au moins une fois : la séparation stricte orthodoxe.

Notre objectif : trouver une méthode qui tire parti des deux approches tout en les respectant au maximum. Je souhaite en effet centraliser toute la validation en un endroit unique, tout en préservant l'isolation stricte des couches de notre Clean Architecture.

`CreateRegulatoryScope` : notre Use Case

L'implémentation de `RegulatoryScope` a pour objectif de permettre l'ajout de nouveaux "scopes" dans un futur configurateur de règles.

Dans cette approche classique, la validation d'unicité métier réside directement au cœur du Use Case :




Et voici le DTO d'exposition HTTP `RegulatoryScopeResource`, portant uniquement les validateurs  de premier niveau  de Symfony classique :


La ressource ici est une mine d'informations avec une belle facilité de lecture pour l'expérience développeur. Centraliser toute la validation ici augmenterait encore plus cette expérience.

Mais cela pose des problèmes architecturaux évidents, n'est-ce pas ?

Si cette logique n'est pas répliquée dans le Use Case ou dans un objet du domaine, que se passe-t-il si le Use Case est appelé depuis une commande CLI, un consommateur de messages, un job ou tout autre point d'entrée que l'API ?

Dans ce cas,  Symfony Validator ne sera pas exécuté, et l'application pourrait enregistrer un regulatory scope en double en base de données, violant ainsi l'intégrité du domaine.

Face à ce dilemme, je propose une approche où toutes les validations  sont exposées dans  le DTO de la ressource, en gardant l'implémentation des validations métiers dans le domaine. 

Cette ADR propose mon approche de centralisation des attributs de validation

en gardant l'isolation des couches.

ADR 0005: Declarative DTO Validation with Infrastructure Delegation and Domain Double-Lock Guard.

Dans l'idée, cela permettra, à la lecture du DTO de la ressource, d'avoir une idée complète de toute la validation mise en place pour une fonctionnalité donnée.

S'il y aLdkpok bien un<p>S'il y a bien un sujet qui a suscité des débats enflammés au sein de mes équipes, c'est celui-ci : <strong>où doit réellement résir ladd efefevalidation des données d'entrée dans une Clean Architecture ?</strong></p>

<p>En fonction des appétences de chacun, des cas d'utilisation ou des contraintes du projet, plusieurs visions s’opposent, avec des arguments très pertinents de part et d’autre. Entre les défenseurs de la pureté absolue du Domaine et ceux qui privilégient la Developer Experience (DX), le dilemme est permanent.</p>

<p>Aujourd'hui, je vous propose d'explorer ce sujet à travers un cas concret : une application <strong>PHP 8.4 / Symfony 8.1</strong> exposée avec <strong>API Platform 4</strong>.</p>

<p>Dans cette stack, la question de la Developer Experience est encore plus prégnante, dans la mesure où Symfony nous offre des outils puissants pour simplifier la validation.</p>

<p>Pourquoi réimplémenter soi-même la validation d’un IBAN alors que Symfony propose de le faire avec un simple attribut <code>#[Assert\Iban]</code> ?</p>

<p>Face à cette question, deux visions s'affrontent régulièrement en Code Review :</p>

<h3>La vision "Developer Experience" (DX & Pragmatique)</h3>
<p>Pour les partisans de cette approche, la réponse est évidente : pourquoi réinventer la roue ?</p>

<p>En ajoutant l'attribut <code>#[Assert\Iban]</code> sur un DTO d'entrée, Symfony s'occupe de tout. Mieux encore : grâce à API Platform 4, la moindre erreur de saisie est interceptée immédiatement et déclenche une réponse HTTP <code>422 Unprocessable Entity</code> parfaitement formatée en <em>Problem Details</em> pour le client API, sans écrire la moindre ligne de code supplémentaire. C'est rapide, propre et standardisé dans l’optique de respecter les standards du Web.</p>

<h3>La vision "Pureté du Domaine" (DDD & Architecture Stricte)</h3>
<p>Pour les défenseurs du Domain-Driven Design, cette facilité est un piège. Si votre règle de validation ne vit que via un attribut Symfony sur un DTO HTTP, que se passe-t-il si votre Use Case est exécuté depuis une commande CLI, un consommateur de message RabbitMQ ou un test unitaire ?</p>

<p>L'enregistrement d'un IBAN invalide devient possible ! Pour cette école, un objet du Domaine (comme un Value Object <code>Iban</code>) doit être son propre gardien : il ne doit jamais pouvoir exister dans un état invalide, quel que soit le canal d'entrée.</p>

<p>Alors, comment trancher sans sacrifier la productivité de l'équipe ni la sécurité de l'application ?</p>

<p>Pour bien comprendre les enjeux, analysons d'abord l'approche que nous avons tous implémentée au moins une fois : la séparation stricte orthodoxe.</p>

<p>Notre objectif : trouver une méthode qui tire parti des deux approches tout en les respectant au maximum. Je souhaite en effet centraliser toute la validation en un endroit unique, tout en préservant l'isolation stricte des couches de notre Clean Architecture.</p>

<h3><code>CreateRegulatoryScope</code> : notre Use Case</h3>
<p>L'implémentation de <code>RegulatoryScope</code> a pour objectif de permettre l'ajout de nouveaux "scopes" dans un futur configurateur de règles.</p>

<p>Dans cette approche classique, la validation d'unicité métier réside directement au cœur du Use Case :</p>

<pre><code class="language-php">namespace App\Application\UseCase\CreateRegulatoryScope;

use App\Domain\Entity\RegulatoryScope;
use App\Domain\Exception\RegulatoryScopeAlreadyExistsException;
use App\Domain\Port\Repository\RegulatoryScopeRepositoryInterface;
use App\Domain\ValueObject\RegulatoryScopeCode;

final readonly class CreateRegulatoryScopeUseCase
{
    public function __construct(
        private RegulatoryScopeRepositoryInterface $regulatoryScopes,
    ) {}

    public function execute(CreateRegulatoryScopeCommand $command): RegulatoryScope
    {
        $code = new RegulatoryScopeCode($command->code);

        // 🛡️ Validation Métier : Contrôle d'unicité en BDD avant création
        if ($this->regulatoryScopes->existsByCode($code)) {
            throw RegulatoryScopeAlreadyExistsException::forCode($command->code);
        }

        // Création de l'agrégat et persistance...
    }
}</code></pre>

<p>Et voici le DTO d'exposition HTTP <strong><code>RegulatoryScopeResource</code></strong>, portant uniquement les validateurs de surface Symfony classique :</p>

<pre><code class="language-php">namespace App\Infrastructure\ApiPlatform\Resource\V1;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Validator\Constraints as Assert;

#[ApiResource(shortName: 'RegulatoryScope', routePrefix: '/v1')]
final class RegulatoryScopeResource
{
    #[Assert\NotBlank(message: 'The code is required.')]
    #[Assert\Regex(
        pattern: '/^[A-Z][A-Z0-9_]*$/',
        message: 'The code must be in UPPERCASE_SNAKE_CASE (e.g. KYC_INDIVIDUAL).'
    )]
    public ?string $code = null;

    #[Assert\NotBlank(message: 'The label is required.')]
    public ?string $label = null;
}</code></pre>

sujet qui a suscité des débats enflammés au sein de mes équipes, c'est celui-ci : **où doit réellement résider la validation des données d'entrée dans une Clean Architecture ?**

En fonction des appétences de chacun, des cas d'utilisation ou des contraintes du projet, plusieurs visions s’opposent, avec des arguments très pertinents de part et d’autre. Entre les défenseurs de la pureté absolue du Domaine et ceux qui privilégient la Developer Experience (DX), le dilemme est permanent.

Aujourd'hui, je vous propose d'explorer ce sujet à travers un cas concret : une application **PHP 8.4 / Symfony 8.1** exposée avec **API Platform 4**.

Dans cette stack, la question de la Developer Experience est encore plus prégnante, dans la mesure où Symfony nous offre des outils puissants pour simplifier la validation.

Pourquoi réimplémenter soi-même la validation d’un IBAN alors que Symfony propose de le faire avec un simple attribut `#[Assert\Iban]` ?

Face à cette question, deux visions s'affrontent régulièrement en Code Review :

### La vision "Developer Experience" (DX & Pragmatique)Pour les partisans de cette approche, la réponse est évidente : pourquoi réinventer la roue ?

En ajoutant l'attribut `#[Assert\Iban]` sur un DTO d'entrée, Symfony s'occupe de tout. Mieux encore : grâce à API Platform 4, la moindre erreur de saisie est interceptée immédiatement et déclenche une réponse HTTP `422 Unprocessable Entity` parfaitement formatée en *Problem Details* pour le client API, sans écrire la moindre ligne de code supplémentaire. C'est rapide, propre et standardisé dans l’optique de respecter les standards du Web.

### La vision "Pureté du Domaine" (DDD & Architecture Stricte)Pour les défenseurs du Domain-Driven Design, cette facilité est un piège. Si votre règle de validation ne vit que via un attribut Symfony sur un DTO HTTP, que se passe-t-il si votre Use Case est exécuté depuis une commande CLI, un consommateur de message RabbitMQ ou un test unitaire ?

L'enregistrement d'un IBAN invalide devient possible ! Pour cette école, un objet du Domaine (comme un Value Object `Iban`) doit être son propre gardien : il ne doit jamais pouvoir exister dans un état invalide, quel que soit le canal d'entrée.

Alors, comment trancher sans sacrifier la productivité de l'équipe ni la sécurité de l'application ?

Pour bien comprendre les enjeux, analysons d'abord l'approche que nous avons tous implémentée au moins une fois : la séparation stricte orthodoxe.

Notre objectif : trouver une méthode qui tire parti des deux approches tout en les respectant au maximum. Je souhaite en effet centraliser toute la validation en un endroit unique, tout en préservant l'isolation stricte des couches de notre Clean Architecture.

### `CreateRegulatoryScope` : notre Use CaseL'implémentation de `RegulatoryScope` a pour objectif de permettre l'ajout de nouveaux "scopes" dans un futur configurateur de règles.

Dans cette approche classique, la validation d'unicité métier réside directement au cœur du Use Case :

```phpnamespaceApp\Application\UseCase\CreateRegulatoryScope;

use App\Domain\Entity\RegulatoryScope;use App\Domain\Exception\RegulatoryScopeAlreadyExistsException;use App\Domain\Port\Repository\RegulatoryScopeRepositoryInterface;use App\Domain\ValueObject\RegulatoryScopeCode;

finalreadonlyclassCreateRegulatoryScopeUseCase{publicfunction__construct(privateRegulatoryScopeRepositoryInterface$regulatoryScopes, ) {}

publicfunctionexecute(CreateRegulatoryScopeCommand$command): RegulatoryScope {$code = newRegulatoryScopeCode($command->code);

// 🛡️ Validation Métier : Contrôle d'unicité en BDD avant créationif ($this->regulatoryScopes->existsByCode($code)) {throwRegulatoryScopeAlreadyExistsException::forCode($command->code); }

// Création de l'agrégat et persistance... }}```

Et voici le DTO d'exposition HTTP **`RegulatoryScopeResource`**, portant uniquement les validateurs de surface Symfony classique :

```phpnamespaceApp\Infrastructure\ApiPlatform\Resource\V1;

use ApiPlatform\Metadata\ApiResource;use ApiPlatform\Metadata\Post;use Symfony\Component\Validator\Constraintsas Assert;

#[ApiResource(shortName: 'RegulatoryScope', routePrefix: '/v1')]finalclassRegulatoryScopeResource{ #[Assert\NotBlank(message: 'The code is required.')] #[Assert\Regex(pattern: '/^[A-Z][A-Z0-9_]*$/',message: 'The code must be in UPPERCASE_SNAKE_CASE (e.g. KYC_INDIVIDUAL).' )]public ?string$code = null;

#[Assert\NotBlank(message: 'The label is required.')]public ?string$label = null;}```La resource ici est une mine d'information avec une belle facilité de lecture pour l'expérience développeur.Centraliser toute la validation ici augmenterai encore plus cette expérience.

Mais cela pose des problèmes architecturaux évidents, n'est-ce pas ?

Si cette logique n'est pas répliquée dans le Use Case ou dans un objet du domaine, que se passe-t-il si le Use Case est appelé depuis une commande CLI, un consommateur de message, un job ou tout autre pointd'entrée que l'API ?

Dans ce cas, ** Symfony Validator ne sera pas exécuté**, et l'application pourrait enregistrer un $code en double en base de données, violant ainsi l'intégrité du domaine.

Face à ce dilemme, il existe plusieurs approches.