Complex domain validation via Symfony Forms & Constraints

962 views Asked by At

I have several complex Domain objects. And I use Symfony forms for validating REST requests over them. Let's assume, I have domain Banner. And I have endpoints for creating/updating the concrete banner by ID with BannerFormType. Banner has limits: daily and total. The business rule is "both limits are required, but daily should be less or equal than total". So we have a situation, where we should use a custom class-constraint for validating two fields linked with each other.

So, my form looks like:

<?php

declare(strict_types=1);

namespace App;

class BannerFormType extends \Symfony\Component\Form\AbstractType
{
    public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('daily_limit', \Symfony\Component\Form\Extension\Core\Type\IntegerType::class, [
                'constraints' => [
                    new \Symfony\Component\Validator\Constraints\NotBlank(),
                ],
            ])
            ->add('total_limit', \Symfony\Component\Form\Extension\Core\Type\IntegerType::class, [
                'constraints' => [
                    new \Symfony\Component\Validator\Constraints\NotBlank(),
                ],
            ])
        ;
    }

    public function configureOptions(\Symfony\Component\OptionsResolver\OptionsResolver $resolver): void
    {
        parent::configureOptions($resolver);

        $resolver->setDefaults([
            'data_class' => Banner::class,
            'constraints' => [
                new BannerLimits(),
            ],
        ]);
    }
}

We code of that constraint looks like:

<?php

declare(strict_types=1);

namespace App;

class BannerLimitsValidator extends \Symfony\Component\Validator\ConstraintValidator
{
    public function validate($banner, \Symfony\Component\Validator\Constraint $constraint): void
    {
        if (!$banner instanceof Banner) {
            throw new \Symfony\Component\Validator\Exception\UnexpectedTypeException($banner, Banner::class);
        }

        if (!$constraint instanceof BannerLimits) {
            throw new \Symfony\Component\Validator\Exception\UnexpectedTypeException($constraint, BannerLimits::class);
        }

        /** @var \Symfony\Component\Form\FormInterface $form */
        $form = $this->context->getObject();

        $totalLimit = $form->get('total_limit')->getNormData();
        $dailyLimit = $form->get('daily_limit')->getNormData();

        if ($totalLimit < $dailyLimit) {
            $this->context
                ->buildViolation('Daily limit should be less or equal than total.')
                ->atPath('daily_limit')
                ->addViolation();
        }
    }
}

Everything works fine, except that we have a very coupled validator. If we want it to reuse for other form, there will be a problem. For example, I would like to add bulk endpoint for updating banners, so it will use BannerListFormType.

Of course, I could pass fields data inside constraint via options, but it's hard to do for a nesting forms. Also, I could call a validator directly inside form event, but it looks strange.

Also, with my approach, it's hard to unit test validator with \Symfony\Component\Validator\Test\ConstraintValidatorTestCase because of coupling constraint with form structure.

What could you advice to me? How you guys solve such a problem?

0

There are 0 answers