Image de couverture : Symfony Validator : où s'arrête le framework, où commence le domaine métier
PHP & Frameworks

Symfony Validator : où s'arrête le framework, où commence le domaine métier

6 juillet 2026
6 min de lecture
16 vues
Sébastien Muler

Symfony Validator : où s'arrête le framework, où commence le domaine métier

Vous avez sûrement déjà écrit ce genre de code : une entité User avec #[Assert\Email] sur le champ email, #[Assert\NotBlank] sur le nom, #[Assert\Length(min: 8)] sur le mot de passe. Les formulaires rejettent les mauvaises saisies, les tests passent, tout le monde est satisfait.

Puis un jour, un job en arrière-plan importe des utilisateurs depuis un CSV, contourne le formulaire, et insère une ligne avec un email vide directement en base de données. Les contraintes existaient bien sur l'entité — mais personne n'a exécuté le validateur sur ce chemin-là. La règle était là. Elle n'était tout simplement pas appliquée là où ça comptait.

C'est exactement ce problème qu'analyse Gabriel Anhaia dans son article publié sur DEV Community, et c'est le cœur de ce que nous allons explorer ici.

Deux portes, deux responsabilités

Imaginez une ligne verticale qui coupe chaque requête en deux.

À gauche : la validation de forme (input). Un corps JSON, une soumission de formulaire, des paramètres d'URL. Ces données arrivent sous forme de chaînes de caractères, il peut en manquer, et elles proviennent d'une source non fiable. La question à poser ici est simple : est-ce que ça ressemble à ce que j'attendais ? L'email est-il une chaîne qui ressemble à un email ? L'âge est-il un entier positif ? Le champ obligatoire est-il présent ?

C'est exactement ce pour quoi Symfony Validator excelle. Il opère à la frontière de votre application, sur des données brutes, avant même que votre domaine métier ne soit sollicité.

À droite : la validation de domaine (métier). Une fois les données structurées et typées, d'autres règles entrent en jeu : est-ce qu'un utilisateur avec cet email existe déjà ? Un client peut-il commander ce produit s'il a un compte bloqué ? Une date de fin peut-elle être antérieure à une date de début ?

Ces règles ne sont pas des contraintes de format. Ce sont des invariants métier. Et elles doivent être appliquées partout où votre domaine est sollicité — formulaire, import CSV, appel API, commande console ou job asynchrone.

Le piège des annotations sur les entités

Le problème classique survient quand on place toute la logique de validation dans des annotations directement sur les entités Doctrine :

#[ORM\Entity]
class User
{
    #[Assert\NotBlank]
    #[Assert\Email]
    private string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    private string $password;
}

Cette approche semble pratique. Mais elle mélange deux niveaux de responsabilité dans le même objet :

  • #[Assert\Email] est une validation de format (input) — utile à la frontière.
  • Une règle comme « l'email doit être unique en base » est une règle de domaine — elle doit être vérifiée dans un service métier.

Lorsque vous n'utilisez que le chemin formulaire, tout va bien. Mais dès qu'un autre point d'entrée bypasse le formulaire, les règles de domaine ne sont plus appliquées. Et dans une application qui grandit, ces points d'entrée alternatifs se multiplient inévitablement.

La bonne séparation : input DTO + domain service

La solution préconisée par une architecture propre consiste à séparer clairement ces deux couches.

Étape 1 : un DTO pour valider la forme

class CreateUserInput
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8)]
    public string $password;
}

Ce DTO n'est pas une entité. C'est un objet de transfert qui représente les données brutes entrantes. Le Symfony Validator s'applique ici, à la frontière, avant que quoi que ce soit d'autre ne se passe.

Étape 2 : un service de domaine pour les règles métier

class UserRegistrationService
{
    public function register(CreateUserInput $input): User
    {
        if ($this->userRepository->findByEmail($input->email)) {
            throw new EmailAlreadyUsedException($input->email);
        }

        // autres invariants métier...

        $user = User::create($input->email, $input->password);
        $this->userRepository->save($user);

        return $user;
    }
}

Ce service métier est appelé quel que soit le point d'entrée : formulaire, API REST, import CSV, commande Symfony. Les règles de domaine sont centralisées et ne peuvent pas être contournées accidentellement.

Étape 3 : le contrôleur orchestre, sans logique

#[Route('/users', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
    $input = $this->serializer->deserialize($request->getContent(), CreateUserInput::class, 'json');

    $errors = $this->validator->validate($input);
    if (count($errors) > 0) {
        return $this->json(['errors' => (string) $errors], 422);
    }

    $user = $this->userRegistrationService->register($input);

    return $this->json($user, 201);
}

Le contrôleur ne contient aucune règle. Il orchestre : désérialisation, validation de forme, appel au domaine, réponse.

Pourquoi cette séparation tient sur le long terme

Cette architecture répond à plusieurs problèmes concrets que rencontrent les équipes sur des projets Symfony en production.

Testabilité : vous pouvez tester votre service de domaine en isolation totale, sans infrastructure HTTP, sans formulaire, sans base de données réelle si nécessaire. Les règles métier deviennent des unités testables claires.

Réutilisabilité : quand le chef de projet demande d'ajouter une commande console pour importer des utilisateurs en masse, vous appelez UserRegistrationService::register() directement. Les règles métier s'appliquent automatiquement.

Lisibilité : un développeur qui rejoint l'équipe comprend immédiatement ce que fait UserRegistrationService. Il n'a pas à déchiffrer quelle contrainte Symfony est une règle métier et laquelle est un simple format check.

Évolutivité : si une règle métier change (par exemple, les emails doivent désormais appartenir à des domaines autorisés), vous la modifiez dans un seul endroit — le service de domaine — et elle s'applique à tous les points d'entrée.

Conclusion

Symfony Validator est un excellent outil. Mais comme tout outil, son efficacité dépend de l'usage qu'on en fait. L'utiliser pour valider la forme des données en entrée, c'est exactement son rôle. L'utiliser comme seul gardien des règles métier, c'est s'exposer à des incohérences dès que l'application grandit.

Séparer la validation d'input (DTO + contraintes Symfony) de la validation de domaine (services métier + invariants explicites) n'est pas une complexité supplémentaire. C'est l'investissement qui permet à votre application de rester maintenable à mesure que les points d'entrée, les règles métier et les équipes évoluent.

📖 Article original : Symfony Validator: Where Framework Validation Ends, Domain Rules Begin par Gabriel Anhaia sur DEV Community.

Partager cet article