Say there’s a Symfony form that requires either one field to be submitted or another field. In this case, it’s okay if both are submitted, but at least one is required.
I had this exact situation come up earlier this week and all the solutions I found were related to adding validation to models. I wanted my form to do the validation, not a model, so I could show the user better errors in respond to bad API requests.
The solution, like all things beyond Symfony form basics, is to use an event listener or subscriber.
How the Form Component Works
The core of the forms component is a set of abstractions for handling form data in general, but actually doesn’t do much. Almost all of the form funtionality used day-to-day is part of a set of form extensions.
For example, should you want to validate a value in some way that’s the validator extension. How about just representing a text field? That’s in the core extension.
The solution to require one field or another relies on the validation extension, specifically this bit that checks to see if the form is synchronized and adds a validation violation if it’s not. That violation becomes part of the form’s errors. Synchronized, in this case, means that the forms data transformers and event listeners fired without a Symfony\Component\Form\Exception\TransformationFailedException
getting thrown.
This isn’t a new trick, the core choice fields (select inputs or sets of radio/checkbox inputs) does it. Which is why you can use a special form option provided by the validator extension to improve error messages on choice fields.
Setting Up the Form Factory and a Form Type
First, here’s the form type we’ll be using. It’s got two text inputs: first
and second
. Neither are required and neither have any sort of validation constraints on them that would mark them as NotBlank
.
<?php declare(strict_types=1); namespace Chrisguitarguy\SymfonyFormOneRequired; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\Extension\Core\Type\TextType; class ExampleFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('first', TextType::class, [ 'required' => false, ]); $builder->add('second', TextType::class, [ 'required' => false, ]); } }
Next here’s our set up code for the form component with the validator extension. This creates a form from the type above then submits it with an empty array. This will work just fine and be valid at this point.
<?php declare(strict_types=1); use Symfony\Component\Form\Forms; use Symfony\Component\Form\Extension\Validator\ValidatorExtension; use Symfony\Component\Validator\Validation; use Chrisguitarguy\SymfonyFormOneRequired\ExampleFormType; require __DIR__.'/vendor/autoload.php'; $formFactory = Forms::createFormFactoryBuilder() ->addExtension(new ValidatorExtension(Validation::createValidator())) ->getFormFactory(); $form = $formFactory->create(ExampleFormType::class); $form->submit([]); if ($form->isSubmitted() && !$form->isValid()) { echo 'form is not valid!', PHP_EOL; foreach ($form->getErrors(true) as $error) { echo "\tERROR: ", $error->getMessage(), PHP_EOL; } }
Requiring at Least One Field
As mentioned in the intro, this is done with an event listener. The easiest way to add an event listener is to use a closure or have the form type itself implement Symfony’s EventSubscriberInterface
. This is perfect if the logic in the event listener does not need to be re-used in an application or if the logic in the event listener doesn’t make sense to test outside of testing the form itself. For example, I tend to have my form types act as event subscribers if I’m using events to conditionally add or change fields — that logic makes little sense outside of the form type and is rarely reused.
We’ll have our form type implement EventSubscriberInterface
and hook into the FormEvents::SUBMIT
event. This events happens after all child form fields have been submitted — right before the form goes into a submitted state — but before model and view data is normalized via data transformers.
Throwing the TransformationFailedException
causes the form to go unsyncrhonized and the validator extension will kick in and use that transformation failure to add a form error, causing the form to be invalid after submit.
<?php declare(strict_types=1); namespace Chrisguitarguy\SymfonyFormOneRequired; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class ExampleFormType extends AbstractType implements EventSubscriberInterface { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('first', TextType::class, [ 'required' => false, ]); $builder->add('second', TextType::class, [ 'required' => false, ]); // telling the form builder about the new event subscriber $builder->addEventSubscriber($this); } public static function getSubscribedEvents() { return [ FormEvents::SUBMIT => 'ensureOneFieldIsSubmitted', ]; } public function ensureOneFieldIsSubmitted(FormEvent $event) { $submittedData = $event->getData(); // just checking for `null` here, but you may want to check for an empty string or something like that if (!isset($submittedData['first']) && !isset($submittedData['second'])) { throw new TransformationFailedException( 'either first or second must be set', 0, // code null, // previous 'Either the first and/or second field must be set', // user message ['{{ whatever }}' => 'here'] // message context for the translater ); } } }
With this new code in place, the form will no longer be in a valid state on submit. Our bootstrap code above will output:
form is not valid! ERROR: Either the first and/or second field must be set
A Note About TransformationFailedException
The fourth and fifth arguments passed to the TransformationFailedException
constructor let you specify the message that appears in the form’s set of errors. If this isn’t supplied the invalid_message
option supplied to the form is used to generate the error message.
In this case, where the code above is not reusable outside of the form type, it makes some sense to include that invalid message in the exception.
Making the Event Listener Reusable
This can be done by pulling the event subscriber stuff into it’s own class. To make it generic, we’ll pass the required fields to check to the constructor.
<?php declare(strict_types=1); namespace Chrisguitarguy\SymfonyFormOneRequired; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\Exception\TransformationFailedException; use Symfony\Component\EventDispatcher\EventSubscriberInterface; final class AtLeastOneRequiredListener implements EventSubscriberInterface { /** * @var string[] */ private $fieldsToCheck; public function __construct(string $firstFieldToCheck, string $secondFieldToCheck, string ...$additionalFieldsToCheck) { $this->fieldsToCheck = [$firstFieldToCheck, $secondFieldToCheck] + $additionalFieldsToCheck; } public static function getSubscribedEvents() { return [ FormEvents::SUBMIT => 'onSubmit', ]; } public function onSubmit(FormEvent $event) { $submittedData = $event->getData(); $emptyFields = []; foreach ($this->fieldsToCheck as $fieldToCheck) { if (!isset($submittedData[$fieldToCheck])) { $emptyFields[] = $fieldToCheck; } } if (count($emptyFields) === count($this->fieldsToCheck)) { throw new TransformationFailedException(sprintf( 'at least one of %s is required', implode(', ', $this->fieldsToCheck) )); } } }
And our form type goes back to a more slimmed down version that adds the newly created listener.
class ExampleFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('first', TextType::class, [ 'required' => false, ]); $builder->add('second', TextType::class, [ 'required' => false, ]); $builder->addEventSubscriber(new AtLeastOneRequiredListener('first', 'second')); } }
Note that the thrown TransformationFailedException
no longer specifics an invalid message to show the user as part of the validation violations. The new listener is no longer qualified to say what that message should be, and so our error becomes more generic:
form is not valid! ERROR: This value is not valid.
This can be improved by passing the invalid_message
to the form options, maybe by setting a default in the form type itself.
<?php declare(strict_types=1); namespace Chrisguitarguy\SymfonyFormOneRequired; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\OptionsResolver\OptionsResolver; class ExampleFormType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { // as above... } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefault('invalid_message', 'Either the first and/or second fields are required'); } }
A Lot of Code to Do Something Simple
Welcome to the Symfony form component. While undoutably powerful, anything beyond simple use cases can get complex very fast.
Nowadays, I like using data transfer objects a bit better.
All of the code above is available on Github