Back to blog posts

Validate Polymorphic Collection Items with Symfony Constraints

Cover Image for Validate Polymorphic Collection Items with Symfony Constraints
Dylan Ballandras
Dylan Ballandras

Handling validation for polymorphic collections of objects can be quite challenging, especially in Symfony. In this article, we'll explore a custom constraint validator that can validate polymorphic collection items directly from the HTTP request.

Imagine a scenario where you have an HTTP request containing an array of objects with different types. You need to validate each object based on its type, and the validation rules may vary for each type. The validation process should be as simple and efficient as possible.

Introducing the PolymorphicCollectionItemValidator

Here's a custom Symfony constraint validator that can handle the polymorphic validation of collections. It validates each item in the collection based on a discriminator field and applies the corresponding validation rules.

PolymorphicCollectionItem Constraint

<?php

declare(strict_types=1);

namespace App\Validator\Constraints;

use Symfony\Component\Validator\Constraint;

class PolymorphicCollectionItem extends Constraint
{
    public string $discriminatorField = 'type';
    /** @var array<string, Constraint> */
    public array $mapping = [];

    /** @param array{discriminatorField: string, mapping: array<string, Constraint>} $options */
    public function __construct($options = null)
    {
        parent::__construct($options);
    }

    public function getRequiredOptions(): array
    {
        return [
            'discriminatorField',
            'mapping',
        ];
    }
}

PolymorphicCollectionItemValidator

<?php

declare(strict_types=1);

namespace App\Validator\Constraints;

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

class PolymorphicCollectionItemValidator extends ConstraintValidator
{
    /**
     * {@inheritdoc}
     */
    public function validate($object, Constraint $constraint): void
    {
        if (!$constraint instanceof PolymorphicCollectionItem) {
            throw new UnexpectedTypeException($constraint, PolymorphicCollectionItem::class);
        }

        if (null === $object) {
            return;
        }

        if (!\is_array($object) && !($object instanceof \Traversable && $object instanceof \ArrayAccess)) {
            throw new UnexpectedValueException($object, 'array|(Traversable&ArrayAccess)');
        }

        $discriminatorField = $constraint->discriminatorField;
        $type = $object[$discriminatorField] ?? null;

        if ($type === null) {
            return;
        }

        $constraintClassName = $constraint->mapping[$type] ?? null;
        if ($constraintClassName === null) {
            $this
                ->context
                ->buildViolation("The '$discriminatorField' is not valid.")
                ->atPath($discriminatorField)
                ->setParameter('{{ type }}', $type)
                ->addViolation()
            ;

            return;
        }

        $this->context->getValidator()
            ->inContext($this->context)
            ->atPath('')
            ->validate($object, $constraintClassName)
            ->getViolations()
        ;
    }
}

Usage

Here's an example of how to use this custom constraint validator in your Symfony application:

<?php

declare(strict_types=1);

namespace App\Validator;

use App\Validator\Constraints\AllowExtraFieldsCollection;
use App\Validator\Constraints\PolymorphicCollectionItem;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints;

class BulkObjectValidator
{
    public function getConstraint(): Constraint
    {
        return new Constraints\Collection([
            'fields' => [
                'objects' => [
                    new Constraints\All([
                        'constraints' => [
                            new Constraints\Collection([
                                'fields' => [
                                    'type' => [
                                        new Constraints\NotNull(),
                                        new Constraints\Choice(['choices' => ['TYPE_1', 'TYPE_2']]),
                                    ],
                                ],
                            ]),
                            new PolymorphicCollectionItem([
                                'discriminatorField' => 'type',
                                'mapping' => [
                                    'TYPE_1' => new Constraints\Collection(['fields' => ['value' => new Constraints\Choice(['choices' => ['VALUE_1', 'VALUE_2']]]),
                                    'TYPE_2' => new Constraints\Collection(['fields' => ['value' => new Constraints\Choice(['choices' => ['VALUE_3', 'VALUE_4']]]),
                                ],
                            ]),
                        ],
                    ]),
                ],
            ],
        ]);
    }
}

For instance, you can use the BulkObjectValidator in a Symfony controller to validate the data from an HTTP request.

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Validator\BulkObjectValidator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;

#[Route('/bulk-objects', methods: ['POST'])]
class BulkObjectsController
{
    public function __construct(private BulkObjectValidator $validator, private ValidatorInterface $validatorInterface)
    {
    }

    public function __invoke(Request $request): Response
    {
        $data = $request->toArray();
        $constraint = $this->validator->getConstraint();
        $violations = $this->validatorInterface->validate($data, $constraint);

        if ($violations->count() > 0) {
            // Handle validation errors
            // ...
        }

        // Process the valid data
        // ...

        return new Response('Success', Response::HTTP_OK);
    }
}

By using the PolymorphicCollectionItemValidator, you can validate the polymorphic data in the objects array directly from the HTTP request. The validator iterates through each item and applies the appropriate validation rules based on the type field.

In conclusion, the PolymorphicCollectionItemValidator offers a simple and powerful way to handle polymorphic validation in Symfony applications. It allows you to validate data directly from the HTTP request and apply different validation rules based on the object's type. Give it a try and let me know your thoughts on Twitter!