Système de Validation avec les Attributs et la Reflection sous Symfony

Système de Validation avec les Attributs et la Reflection sous Symfony

Symfony - lundi 19 février 2024


Introduction

Explorez les coulisses de Symfony et découvrez les secrets des attributs et de la reflection PHP !

Dans l'écosystème complexe de Symfony, ces fonctionnalités offrent un potentiel énorme pour la création d'applications web robustes et flexibles.

Les attributs, introduits depuis la version 5.4 de Symfony, et la reflection PHP sont souvent considérés comme des outils avancés réservés aux développeurs chevronnés.

Pourtant, leur utilisation peut transformer votre approche du développement et ouvrir de nouvelles perspectives.

Dans cet article, plongez dans le monde des attributs et de la reflection PHP en explorant un exemple concret : la création d'un système de validation des données.

Bien que ce système soit un simple prétexte, il nous offre une occasion idéale de mettre en pratique les attributs et la reflection PHP dans un contexte réel.

N'oublions pas : notre objectif n'est pas de remplacer composant Validator de Symfony, mais plutôt de comprendre comment les attributs et la reflection PHP peuvent enrichir notre boîte à outils de développement.

Préparez-vous à plonger dans les méandres de Symfony et à découvrir les possibilités infinies des attributs et de la reflection PHP !

Définition

Attributs

Les Attributs sont des informations de métadonnées structurées qu’il est possible d’ajouter comme déclaration sur des classes, des méthodes, des fonctions, des propriétés, et des constantes en PHP.

Ces métadonnées peuvent être inspectées au moment de l’exécution avec l’API de Reflection.

Les attributs sont extrêmement utiles pour mettre en œuvre des fonctionnalités de manière générique, flexible, et réutilisable dans divers contextes de développement.

Reflection

La Reflection est une API native de PHP qui nous permet d'examiner la structure interne du code pendant l'exécution.

Elle nous donne accès à diverses informations et métadonnées sur les éléments du code, y compris les attributs que nous avons ajoutés sur les classes, les méthodes, les fonctions, les propriétés et les constantes.

La Reflection nous permet également d'accéder à d'autres informations telles que les noms, les signatures et les annotations.

Installation du projet

Commencez par créer un nouveau projet Symfony avec la commande suivante :

symfony new DemoAttributs

Ensuite, installez les composants nécessaires pour la gestion de la base de données et la création de fichiers avec MakerBundle :

composer require symfony/orm-pack
 composer require --dev symfony/maker-bundle

Démarrez votre conteneur PostgreSQL avec Docker.

Création de l'Entité

Générez une entité "User" avec les champs name, email et password en utilisant la commande suivante :

symfony console make:entity

Générez et appliquez ensuite la migration pour mettre à jour votre base de données.

symfony console make:migration
symfony console doctrine:migrations:migrate

Pour en savoir plus sur les migrations vous pouvez consulter mon guide pratique sur le sujet : ici

Création du Contrôleur

Le contrôleur que nous allons créer simule la création d'un utilisateur.

Nous allons le configurer pour créer automatiquement un utilisateur lorsque vous accédez à la racine de votre site.

Générez le contrôleur UserController avec la commande :

symfony console make:controller

Modifiez le fichier UserController généré comme suit :

  • Changez la route pour qu'elle soit la racine de votre site.
  • Injectez l'EntityManager en tant que paramètre de votre méthode pour pouvoir enregistrer l'utilisateur.
  • Ajoutez la logique de création de l'utilisateur avec des valeurs arbitraires.
  • Retournez une JsonResponse pour informer que l'utilisateur a été crée
#[Route('/', name: 'homepage')]
public function index(EntityManagerInterface $entityManager): JsonResponse
{
    $user = (new User())
        ->setName('name')
        ->setEmail('email')
        ->setPassword('password')
    ;

    $entityManager->persist($user);
    $entityManager->flush();

    return $this->json([
        'message' => 'Utilisateur crée',
    ]);
}

Testez maintenant votre URL pour vous assurer que l'utilisateur est créé avec vos valeurs.

Super ! L'utilisateur est créé.

Cependant, nous souhaitons maintenant avoir un contrôle plus précis sur les données que nous recevons, comme imposer une longueur spécifique pour le nom, exiger une adresse e-mail valide, ou renforcer la robustesse du mot de passe.

Attributs

Créons un répertoire Constraints, et dans celui ci ajoutons trois classes d’attributs pour contrôler nos données :

Classe Length

La classe Length prendra en compte les paramètres min et max dans son constructeur :

<?php

namespace App\Constraints;

#[\Attribute(Attribute::TARGET_PROPERTY)]
class Length
{
    public function __construct(public int $min, public int $max)
    {
    }
}

Classe Email

La classe Email est une classe vide :

<?php

namespace App\Constraints;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Email
{
}

Classe Password

La classe Password est également une classe vide :

<?php

namespace App\Constraints;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Password
{
}

Les attributs #[Attribute] déclarent que nos classes seront utilisées comme attributs.

Le flag Attribute::TARGET_PROPERTY précise le type de données sur lequel nous utiliserons ces attributs.

Pour plus de détails, consultez la documentation PHP sur les Déclarations des classes d’attributs.

Maintenant que nos attributs sont créés, nous pouvons les appliquer aux propriétés de notre entité User :

#[ORM\Column(length: 255)]
#[Length(min: 3, max:30)]
private ?string $name = null;

#[ORM\Column(length: 255)]
#[Email]
private ?string $email = null;

#[ORM\Column(length: 255)]
#[Length(min:8, max:16)]
#[Password]
private ?string $password = null;

Nous avons ajouté :

  • #[Length(min: 3, max: 30)] sur le name.
  • #[Email] sur l’email.
  • #[Length(min: 8, max: 16)] et #[Password] sur le password.

N'oubliez pas d'importer vos classes d’attributs dans votre classe User.

Maintenant que la base est en place, nous devons lire et traiter ces attributs.

Pour cela, nous aurons besoin d’un service qui utilise l’API de Reflection de PHP lors du processus de persistance des données par Doctrine.

Event Listener

Dans cette section, nous allons très rapidement aborder le sujet des écouteurs d'événements.

Mais cela pourrait faire l'objet d'un article distinct à l'avenir.

Classe ConstraintsListener

Nous créons une classe ConstraintsListener dans un nouveau répertoire EventListener.

Cette classe sera un écouteur d'événements Doctrine et sera déclenchée lors de l'événement prePersist.

Nous utilisons l'attribut #[AsDoctrineListener(event: Events::prePersist, priority: 500, connection: 'default')] pour déclarer notre classe en tant qu'écouteur d'événements Doctrine, avec une priorité élevée pour une exécution rapide dans le cycle de vie et enfin nous indiquons sur quelle connexion nous voulons agir.

<?php

namespace App\EventListener;

use App\Services\ConstraintsValidation;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Events;

#[AsDoctrineListener(event: Events::prePersist, priority: 500, connection: 'default')]
class ConstraintsListener
{
    public function __construct(private readonly ConstraintsValidation $constraintsValidation)
    {
    }

    public function prePersist(PrePersistEventArgs $event): void
    {
        $this->constraintsValidation->validate($event->getObject());
    }
}

Classe ConstraintsValidation

Dans un nouveau répertoire Services, nous ajoutons une classe ConstraintsValidation qui contient une méthode validate.

Pour l'instant, cette méthode affiche simplement l'objet reçu en argument à l'aide de dd() (dump & die).

<?php

namespace App\Services;

class ConstraintsValidation
{
    public function validate(object $object): void
    {
        dd($object);
    }
}

Pensez bien à importer le service dans votre Listener.

En réactualisant la page, vous devriez voir le résultat du dd() de votre service, ce qui indique que tout fonctionne comme prévu.

Maintenant, nous pouvons nous concentrer sur la lecture des attributs avec les Reflections.

Reflections

Dans cette section, nous allons explorer pas à pas le fonctionnement des Reflections et comment les utiliser.

Instanciation d'une ReflectionClass

Nous commençons par créer une nouvelle ReflectionClass en passant l'objet sur lequel nous travaillons (notre utilisateur) dans le constructeur.

public function validate(object $object): void
{
    $reflectionClass = new \ReflectionClass($object);

    // Dump & die pour explorer les informations disponibles
    dd($reflectionClass);
}

La sortie de notre dd() nous donne un aperçu détaillé des propriétés et des attributs de notre classe User.

Boucle sur les Propriétés et les Attributs

Nous itérons sur les propriétés de la classe User et leurs attributs pour vérifier s'ils correspondent à nos contraintes de validation.

Dans un premier temps, nous nous concentrons sur la contrainte la plus simple à gérer : l'adresse email.

Validation de l'Attribut Email

Nous vérifions si l'attribut correspond à la contrainte Email et si la valeur de l'objet est une adresse email valide.

Si ce n'est pas le cas, nous lançons une exception avec un message d'erreur approprié.

public function validate(object $object): void
{
    $reflectionClass = new \ReflectionClass($object);
    $properties = $reflectionClass->getProperties();

    foreach ($properties as $property) {
        $attributes = $property->getAttributes();

        foreach ($attributes as $attribute) {
            $value = $property->getValue($object);

            if ($attribute->getName() === Email::class) {
                if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
                    $message = sprintf('La propriété %s doit être dans un format valide.', $propertyName);
                    throw new \InvalidArgumentException($message);
                }
            }
        }
    }
}

Vous pouvez tester, votre validateur d'Email !

Si votre email est une adresse valide, l'utilisateur sera crée, si ce n'est pas le cas vous devriez obtenir votre message d'erreur.

Super ! Continuons avec les autres attributs

Validation de l'Attribut Length

Pour la contrainte Length, nous gérons la validation en récupérant les arguments de l'attribut et en vérifiant que la longueur de la valeur correspond aux limites spécifiées.

Si la longueur est invalide, une exception sera levée.

foreach ($attributes as $attribute) {
    $value = $property->getValue($object);

    if ($attribute->getName() === Email::class) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            $message = sprintf('La propriété %s doit être dans un format valide.', $propertyName);
            throw new \InvalidArgumentException($message);
        }
    }

    if ($attribute->getName() === Length::class) {
        $arguments = $attribute->getArguments();

        $min = $arguments['min'];
        $max = $arguments['max'];
        $length = strlen($value);

        if ($length < $min || $length > $max) {
            $message = sprintf('La longueur de la propriété "%s" doit être comprise entre %d et %d caractères.',
                $propertyName,
                $min,
                $max
            );
            throw new \InvalidArgumentException($message);
        }
    }
}

Une nouvelle fois vous pouvez verifier que votre validateur fonctionne correctement.

Dans le cas où votre name a une longueur comprise entre 3 et 30 caractères et votre mot de passe entre 8 et 16, l'enregistrement de votre User devrait se faire sans problème, sinon l'erreur doit s'afficher.

Refactorisation du Code

Je vous propose de refactoriser le code en créant des classes distinctes pour chaque attribut de validation.

Cela rendra le code plus lisible et plus maintenable, surtout lorsque de nouveaux attributs seront ajoutés à l'application.

Commençons par créer une nouvelle classe EmailValidator que nous mettrons dans le même dossier que les attributs Constraints.

Créons dans cette classe une méthode validate et déplaçons notre logique de vérification d'email dans cette méthode.

<?php

namespace App\Constraints;

class EmailValidator
{
    public function validate($value, $propertyName)
    {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            $message = sprintf('La propriété %s doit être dans un format valide.', $propertyName);
            throw new \InvalidArgumentException(json_encode($message, JSON_UNESCAPED_UNICODE));
        }
    }
}

Faisons de même pour la contrainte Length.

Voici donc la classe LengthValidator après le déplacement de la logique :

<?php

namespace App\Constraints;

class LengthValidator
{
    public function validate($value, $propertyName, $arguments)
    {
        $min = $arguments['min'];
        $max = $arguments['max'];
        $length = strlen($value);

        if ($length < $min || $length > $max) {
            $message = sprintf('La longueur de la propriété "%s" doit être comprise entre %d et %d caractères.',
                $propertyName,
                $min,
                $max
            );
            throw new \InvalidArgumentException(json_encode($message, JSON_UNESCAPED_UNICODE));
        }
    }
}

Nous observons que nos deux validateurs sont très similaires avec la même méthode et recevant pratiquement les mêmes paramètres.

Pour assurer la cohérence et faciliter la gestion des futurs validateurs, nous allons créer une interface Validator.

Cette interface garantira que tous nos validateurs respectent le même contrat.

Créons donc notre Interface Validator que nous placerons également dans le dossier Constraints.

<?php

namespace App\Constraints;

interface Validator
{
    public function validate($value, string $propertyName, array $arguments = []): void;
}

Implémentons maintenant notre interface dans nos deux validateurs.

class EmailValidator implements Validator
{
    //...
}
class LengthValidator implements Validator
{
    //...
}

Modifions maintenant notre service ConstraintsValidation pour automatiser les appels à nos validateurs en fonction des attributs.

namespace App\Services;

use App\Constraints\Validator;

class ConstraintsValidation
{
    public function validate(object $object): void
    {
        $reflectionClass = new \ReflectionClass($object);

        $properties = $reflectionClass->getProperties();

        foreach ($properties as $property) {
            $attributes = $property->getAttributes();

            foreach ($attributes as $attribute) {
                $validatorClassName = $attribute->getName() . 'Validator';

                if (class_exists($validatorClassName) && in_array(Validator::class, class_implements($validatorClassName))) {
                    $value = $property->getValue($object);
                    $propertyName = $property->getName();
                    $arguments = $attribute->getArguments();

                    $validator = new $validatorClassName();

                    $validator->validate($value, $propertyName, $arguments);
                }
            }
        }
    }
}

Vérification de l'existence et de l'implémentation du validateur :

  • Pour chaque attribut de chaque propriété, nous construisons le nom de la classe du validateur en ajoutant le suffixe Validator au nom de l'attribut.
  • Nous vérifions ensuite si la classe du validateur existe et si elle implémente bien notre interface Validator.
$validatorClassName = $attribute->getName() . 'Validator';

if (class_exists($validatorClassName) && in_array(Validator::class, class_implements($validatorClassName))) {

Validation de la propriété avec le validateur approprié :

  • Si le validateur existe et implémente l'interface Validator, nous récupérons la valeur de la propriété, son nom et les arguments associés à l'attribut.
  • Nous instancions ensuite le validateur correspondant et appelons sa méthode validate() avec les paramètres appropriés.
$value = $property->getValue($object);
$propertyName = $property->getName();
$arguments = $attribute->getArguments();

$validator = new $validatorClassName();
$validator->validate($value, $propertyName, $arguments);

Ainsi, ce service automatise les appels aux validateurs associés aux attributs de la classe, ce qui permet d'ajouter de nouveaux attributs de contraintes en créant simplement un nouvel attribut avec son validateur correspondant.

Cela rend le code plus modulaire, facilement extensible et maintenable.

Ajout du validateur pour l'attribut #[Password]

Création de la classe PasswordValidator :

  • Nous avons déjà une classe d'attribut pour Password, donc nous ajoutons le validateur correspondant.
  • La classe PasswordValidator implémente l'interface Validator pour garantir un comportement cohérent.
class PasswordValidator implements Validator
{
    public function validate($value, string $propertyName, array $arguments = []): void
    {
        $regex = '/^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[?!@#$%^&*()-_=+{};:,<.>]).*$/';

        if (!preg_match($regex, $value)) {
            $message = sprintf('La propriété %s doit comporter au moins 1 chiffre, 1 lettre minuscule, 1 lettre majuscule et 1 caractère spécial.', $propertyName);
            throw new \InvalidArgumentException(json_encode($message, JSON_UNESCAPED_UNICODE));
        }
    }
}

Validation des critères du mot de passe :

  • Nous ajoutons une expression régulière ($regex) pour vérifier la complexité du mot de passe, en exigeant au moins un chiffre, une minuscule, une majuscule et un caractère spécial.

Utilisation du validateur :

  • Comme pour les autres validateurs, nous utilisons le même processus pour instancier et appeler le PasswordValidator dans le service ConstraintsValidation.

En suivant cette approche, l'ajout de nouveaux attributs avec leurs validateurs associés devient une tâche simple et cohérente, ce qui rend le système flexible et facilement extensible.

Vérifions une dernière fois que nos 3 Attributs fonctionnent comme attendu.

Ça fonctionne ? Parfait !

Nous venons de créer un système simple et basique de validation des données, facilement réutilisable, maintenable et extensible à des futurs besoins.

Conclusion

En explorant les attributs et l'API de reflection de PHP, cet article a offert un aperçu de la flexibilité et de la réutilisabilité qu'ils offrent dans la création de systèmes de validation de données.

Bien que nous ayons démontré comment ces outils peuvent être utilisés de manière créative, il est crucial de rappeler que le composant Validator de Symfony demeure la solution privilégiée pour des validations robustes et complètes.

Cependant, en comprenant les mécanismes sous-jacents de la reflection et des attributs, les développeurs peuvent enrichir leurs compétences et explorer de nouvelles possibilités pour des applications PHP plus dynamiques et évolutives.

Sources