Créer un validateur custom sur Symfony 5

Cela faisait un moment mais voici un nouveau billet qui je pense peut-être utile, et comme le titre l'indique, il est question de la création d'un validateur custom sur Symfony 5 (et PHP 8).

Déjà pourquoi faire un validateur custom ? C'est utile quand une règle de validation pour l'insertion est trop complexe pour être fait avec avec les validateurs existants sous Symfony (le @Assert\).

Et les contraintes de validation sont utiles car elles sont exécutés au moment de la lecture/modification de l'objet, ce qui signifie qu'elles peuvent être déclenchés par exemple lors de chargement de fixture (via AliceBundle par exemple) afin d'être certain de ne pas mettre de mauvaises données dans la base de données.

Premièrement il faut créer l'attribut de contrainte qui sera placé sur la classe (dans le cas où la contrainte utilise plusieurs propriétés de la classe) ou sur la propriété quand la règle est un peu plus simple et n'utilise que la propriété en question.

use Symfony\Component\Validator\Constraint;

#[\Attribute]
class BookPublishedStatus extends Constraint
{
    public string $message = "Vous ne pouvez pas modifier l'auteur du livre quand celui-ci est publié.";
    public string $mode = 'strict';
    
    // À ajouter si la contrainte se situe sur la classe elle-même
    public function getTargets(): array|string
    {
    	return self::CLASS_CONSTRAINT;
    }
}

Une fois la contrainte faite, il faut créer la classe du validateur qui va effectivement vérifier la contrainte qu'on tente de vérifier.

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

class BookPublishedStatusValidator extends ValidatorConstraint
{
    public function validate($value, Constraint $constraint): void
    {
    	if (!$constraint instanceof BookPublishedStatus) {
        	throw new UnexpectedTypeException($constraint, BookPublishedStatus::class);
        }
        
        if (!$value instance of Book) {
        	throw new UnexpectedTypeException($value, Book::class);
        }
        
        // Permet de récupérer l'objet tel qu'il était avant sa modification
        $previousData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($value);
        
        if (
        	$value->getStatus()?->getCode() !== BookStatusCodeEnum::UNRELEASED
        	&& $previousData['author']->getId() !== $value->getAuthor()->getId()
        ) {
        	$context = $this->context;
        
        	$context->buildViolation($constraint->message)
                ->atPath('book')
                ->addViolation();
        }
    }
}

Et il faut évidemment ajouter l'attribut sur l'entité :

use App\Validator\BookPublishedStatus;

#[BookPublishedStatus]
class Book {
	// ...
}

Cette contrainte par exemple empêchera de changer l'auteur d'un livre quand le livre est publié. Et cette contrainte sera vérifié à chaque fois qu'un objet de type Book sera modifié.

L'exemple que je donne est overkill car normalement je devrais le mettre que sur la propriété $author. Si j'avais besoin de récupérer l'objet où la contrainte est utilisée, il suffirait de d'ajouter cette ligne dans le validateur :

$object = $this->context->getObject();

Et ça devrait récupérer l'instance qui est en cours de modification.

Donc voilà c'était un petit exemple sur la construction d'un validateur custom sur Symfony.