How to dynamically validate some data with Symfony Validator
From time to time, you have to validate data according to another value, or group of values.
We can do that quickly with plain PHP in a callback, or in a dedicated constraints like following:
class MyDto
{
public bool $sendEmail;
public string $email;
#[Assert\Callback()]
public function validate(ExecutionContextInterface $context): void
{
if ($this->sendEmail) {
if (!isEmailValid($this->email)) {
$context
->buildViolation('The email is not valid')
->atPath('email')
->addViolation()
;
}
}
}
}
But Ho! Where does the isEmailValid
function came from? It would be better to be able to reuse all Symfony Constraints available, isn’t it?
This is what you’ll learn by reading this article.
Section intitulée use-caseUse case
Let’s take a more complex example to learn many new things!
Consider this class:
class Trigger
{
private const TYPES = [
'url',
'requestHeaders',
'requestMethods',
'responseStatusCodes',
];
#[Assert\NotBlank()]
#[Assert\Choice(choices: self::TYPES)]
public string $type;
public array $options = [];
}
And I would like to validate the following payloads:
{
"type": "url",
"options": {
"url": "http://google.fr"
}
},
or that:
{
"type": "responseStatusCodes",
"options": {
"statusCodes": [
200
]
}
}
So, how can we leverage Symfony to validate the options
data according to type
dynamically?
There are few options, let’s discover them.
Section intitulée with-code-callback-code-not-reusableWith Callback
(not reusable)
class Trigger
{
// ...
#[Assert\Callback()]
public function validate(ExecutionContextInterface $context): void
{
// Avoid PHP errors and also reporting the same errors many times.
if (!isset($this->type) || !\in_array($this->type, self::TYPES, true)) {
return;
}
// Get a groups a constraints for each `type` value
$constraints = match ($this->type) {
'url' => $this->getUrlConstraints(),
'requestHeaders' => $this->getRequestHeadersConstraints(),
'requestMethods' => $this->getRequestMethodsConstraints(),
'responseStatusCodes' => $this->getResponseStatusCodes(),
default => throw new \UnexpectedValueException(),
};
// All the magic occurs here!
// We create a new validator, but in the very same context
// And we suffix the `options` path
$context
->getValidator()
->inContext($context)
->atPath('options')
->validate($this->options, $constraints)
;
}
private function getUrlConstraints(): array
{
return [
// Since options should be an array, we use the `Collection` constraints
// By default, all fields are required, so NotBlank are not required here
new Assert\Collection([
'url' => [
new Assert\Url(),
],
]),
];
}
private function getRequestHeadersConstraints(): array
{
return [
// Since options should be an array, we use the `Collection` constraints
// In this case, all fields are not required, so we must add NotBlank where needed
new Assert\Collection([
'fields' => [
'name' => [
new Assert\NotBlank(),
new Assert\Type('string'),
],
'value' => [
new Assert\Type('string'),
],
'operator' => [
new Assert\Choice(choices: Header::TYPE_CHOICES),
],
],
'allowMissingFields' => true,
]),
// We can go deeper: we can even apply again the same principle to validate some part
// of the $option according to another sub options value
new Assert\Callback(function (array $options, ExecutionContextInterface $context) {
// Avoid reporting many times the same errors.
if (!\array_key_exists('operator', $options)) {
return;
}
if (\in_array($options['operator'], Header::TYPE_NEED_VALUE, true)) {
$constraints = [new Assert\NotBlank()];
} else {
$constraints = [new Assert\Blank()];
}
// Let's run again the validator on some part of the options
$context
->getValidator()
->inContext($context)
->atPath('value')
->validate($options['value'] ?? null, $constraints)
->getViolations()
;
}),
];
}
// ...
}
Section intitulée with-dedicated-code-constraint-code-reusableWith dedicated Constraint
(reusable)
In this case, we want to be able to reuse the constraint, so we create a couple of Constraint
and ConstraintValidator
.
Section intitulée the-code-constraint-codeThe Constraint
#[\Attribute(\Attribute::TARGET_CLASS)]
class TriggerType extends Constraint
{
public function getTargets()
{
return self::CLASS_CONSTRAINT;
}
}
Section intitulée the-code-constraintvalidator-codeThe ConstraintValidator
class TriggerTypeValidator extends ConstraintValidator
{
public function __construct(
// This service locator contains many Constraints Builder
// The key is the Trigger `type`, and the value is the Builder
private ContainerInterface $definitions,
) {
}
public function validate($trigger, Constraint $constraint)
{
$type = $trigger->type ?? null;
// Avoid reporting many times the same errors.
if (!$type) {
return;
}
if (!$this->definitions->has($type)) {
$this
->context
->buildViolation('The type is not valid.')
->atPath('type')
->setParameter('{{ type }}', $type)
->addViolation()
;
return;
}
/** @var TriggerDefinitionInterface */
$definition = $this->definitions->get($type);
// All the magic occurs here!
// It's exactly the same mechanism as with the Callback Constraint
$this->context
->getValidator()
->inContext($this->context)
->atPath('options')
->validate($trigger->options, $definition->getConstraints())
;
}
}
Have you noticed the ContainerInterface $definitions
?
In order to have a system that is really generic, and extensible, we have created a new interface TriggerDefinitionInterface
.
Then, according to the trigger type, we match the definition, and we validate the data according to it.
Section intitulée the-code-triggerdefinitioninterface-code-interfaceThe TriggerDefinitionInterface
Interface
Implementation has to build the constraints (getConstraints
) associated with a trigger type (getType
).
interface TriggerDefinitionInterface
{
public static function getType(): string;
public function getConstraints(): array;
}
An example:
class SliceTriggerTypeDefinition implements TriggerDefinitionInterface
{
public static function getType(): string
{
return 'slice';
}
public function getConstraints(): array
{
return [
new Assert\Collection([
'from' => [
new Assert\Type('int'),
],
'to' => [
new Assert\Type('int'),
],
]),
];
}
}
Section intitulée dependency-injection-containerDependency Injection Container
And now, let’s leverage Symfony DIC features to register all TriggerDefinition
and inject them in our validator:
services:
_defaults:
autowire: true
autoconfigure: true
_instanceof:
App\Trigger\TriggerDefinitionInterface:
tags:
- { name: trigger.type.definition }
App\Validator\Constraints\TriggerTypeValidator:
arguments:
$definitions: !tagged_locator { tag: trigger.type.definition, default_index_method: 'getType'}
We use two main features:
-
_instanceof
to automatically add a tag on service that implementsTriggerDefinitionInterface
-
!tagged_locator
to create a Service Locator with all our services. And in order to not break the service’s lazy loading, we usedefault_index_method
. This method will be called to assign the service name in the service locator.
Section intitulée conclusionConclusion
That’s all, with not so much code, we have build a very powerful system to validate some data according to some data dynamically!
This system behaves really well when the data shape is really generic. For example in a CMS (block system), in e-commerce (product details), etc.
But, you should not abuse too much of it. Usually, it would be better to have dedicated DTO for each situation.
Commentaires et discussions
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Dans le cadre du renouveau de sa stratégie digitale, Orpi France a fait appel à JoliCode afin de diriger la refonte du site Web orpi.com et l’intégration de nombreux nouveaux services. Pour effectuer cette migration, nous nous sommes appuyés sur une architecture en microservices à l’aide de PHP, Symfony, RabbitMQ, Elasticsearch et Docker.
Nous avons épaulé Adrenaline Hunter, juste avant le lancement public de ses offres, sur des problématiques liées à la performance de leur application développée avec Symfony. Nous avons également mis en place un système de workflow des commandes de séjours afin que toutes les actions avec leurs différents partenaires soient réparties avec fiabilité…
Dans le cadre d’une refonte complète de son architecture Web, Expertissim a sollicité l’expertise de JoliCode afin de tenir les délais et le niveau de qualité attendus. Le domaine métier d’Expertissim n’est pas trivial : les spécificités du marché de l’art apportent une logique métier bien particulière et un processus complexe. La plateforme propose…