Add a custom conditional validation on a Symfony form

Published on 2019-03-26 • Modified on 2019-03-28

Sometimes validating each field of a form is not enough. In some cases you need a conditional validation. That means that the validation of a field or a set of fields will be conditioned by another field's value. Here is a simple example showing how to validate an end date, but only if a value was set. It will also show you how to manually submit values to a form without using the current request.


<?php

declare(strict_types=1);

// src/Form/EventCreateType.php

namespace App\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * We set a global validation on the form. Not on a specific field.
 */
final class EventCreateType extends AbstractType
{
    /**
     * We remove csrf as we manually submit values to the form.
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'csrf_protection' => false,
            'constraints' => [
                new Callback([$this, 'validate']),
            ],
        ]);
    }

    /**
     * Valid if the end date if not set or if it is greater than the start date.
     * If the second test, we are sure both fields are DateTime objects.
     *
     * @param array<string,mixed> $data
     */
    public function validate(array $data, ExecutionContextInterface $context): void
    {
        if (($data['end_date'] instanceof \DateTime) && $data['start_date'] > $data['end_date']) {
            $context->buildViolation('The end date must be greater than the start date.')
                ->atPath('end_date')
                ->addViolation();
        }
    }

    /**
     * Only the start date is mandatory.
     *
     * @param array<string,mixed> $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('start_date', DateType::class, [
            'widget' => 'single_text',
            'constraints' => [new NotBlank()],
        ]);

        $builder->add('end_date', DateType::class, [
            'widget' => 'single_text',
        ]);
    }
}
Bonus, the snippet to run this code: 🎉
<?php

declare(strict_types=1);

namespace App\Controller\Snippet;

use App\Form\EventCreateType;
use Symfony\Component\Form\FormFactoryInterface;

/**
 * I am using a PHP trait to isolate each snippet in a file.
 * This code should be called from a Symfony controller extending AbstractController (as of Symfony 4.2)
 * or Symfony\Bundle\FrameworkBundle\Controller\Controller (Symfony <= 4.1).
 * Services are injected in the main controller constructor.
 *
 * @property FormFactoryInterface $formFactory
 */
trait Snippet20Trait
{
    /**
     * Test the validation with a set of values.
     */
    public function snippet20(): void
    {
        $formValues = [
            [
                'start_date' => '2019-03-26', // valid
            ],
            [
                'start_date' => '2019-03-27',
                'end_date' => '2019-03-20', // NOT valid
            ],
            [
                'start_date' => '2019-03-28',
                'end_date' => '2019-03-29', // valid
            ],
        ];

        // Manually submit values to the form. Note that the form creation is in
        // the loop because a form can only be submitted once
        foreach ($formValues as $formValue) {
            $form = $this->formFactory->create(EventCreateType::class);
            $form->submit($formValue);
            if ($form->isValid()) {
                /** @var array{start_date: \DateTime, end_date: \DateTime|null} */
                $data = $form->getData();
                $startDate = $data['start_date'];
                echo 'Form is valid! start_date: '.$startDate->format('Y-m-d');
            } else {
                echo 'Form is not valid: '.$form->getErrors(true);
            }
            echo PHP_EOL;
        }

        // That's it! 😁
    }
}

 Run this snippet  ≪ this.showUnitTest ? this.trans.hide_unit_test : this.trans.show_unit_test ≫  More on Stackoverflow   Read the doc  Random snippet

  Work with me!

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Controller\Snippets;

use App\Form\EventCreateType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;

/**
 * @see Snippet20Trait
 */
final class Snippet20Test extends KernelTestCase
{
    private FormFactoryInterface $formFactory;

    protected function setUp(): void
    {
        $this->formFactory = self::getContainer()->get('form.factory');
    }

    /**
     * @return iterable<int, array{0: string, 1: ?string, 2: bool}>
     */
    public function provide(): iterable
    {
        yield ['2019-03-26', null, true];
        yield ['2019-03-27',  '2019-03-20', false];
        yield ['2019-03-28', '2019-03-29', true];
    }

    /**
     * @see Snippet20Trait::snippet20
     *
     * @dataProvider provide
     */
    public function testSnippet20(string $startDate, ?string $endDate, bool $isValid): void
    {
        $form = $this->formFactory->create(EventCreateType::class);
        $form->submit([
            'start_date' => $startDate,
            'end_date' => $endDate,
        ]);
        self::assertSame($form->isValid(), $isValid);
    }
}