Symfony - lundi 19 février 2024
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 !
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.
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.
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.
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
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 :
EntityManager
en tant que paramètre de votre méthode pour pouvoir enregistrer l'utilisateur.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.
Créons un répertoire Constraints
, et dans celui ci ajoutons trois classes d’attributs pour contrôler nos données :
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)
{
}
}
La classe Email
est une classe vide :
<?php
namespace App\Constraints;
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class Email
{
}
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.
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.
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());
}
}
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.
Dans cette section, nous allons explorer pas à pas le fonctionnement des Reflections et comment les utiliser.
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
.
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.
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
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.
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 :
Validator
au nom de l'attribut.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é :
Validator
, nous récupérons la valeur de la propriété, son nom et les arguments associés à l'attribut.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.
Création de la classe PasswordValidator :
Password
, donc nous ajoutons le validateur correspondant.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 :
Utilisation du validateur :
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.
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.