Symfony 8.2 brings many improvements to the Form component, from new form types and options to more flexible multi-step forms and form events.
Defining Ranges with BoundsType
Search and filter forms often ask for a range of values: a minimum and maximum
price, a start and end date, etc. Until now, you had to add two separate fields
and validate their order yourself. Symfony 8.2 adds a BoundsType that renders
a lower and an upper bound of the same inner type, the same way RepeatedType
renders the same type twice:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
use Symfony\Component\Form\Extension\Core\Type\BoundsType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\MoneyType;
$builder
->add('price', BoundsType::class, [
'type' => MoneyType::class,
// options applied to both bounds
'options' => ['currency' => 'EUR'],
// options applied to only one of the bounds
'from_options' => ['label' => 'Min. price'],
'to_options' => ['label' => 'Max. price'],
// checks that the lower bound is not greater than the upper bound
'compare' => true,
])
->add('publishedAt', BoundsType::class, [
'type' => DateType::class,
]);
The two bounds are regular child forms called from and to, so they map
to an array{from: mixed, to: mixed} property or to an object with those
properties. If your model uses other names, set property_path in
from_options and to_options (e.g. minPrice and maxPrice).
The compare option orders scalars, dates and enums (in their declaration
order) natively. For other values, pass a callable that compares the two bounds.
The error is reported on the lower bound, which is the one users usually change
to fix the range.
Nested and Grouped Steps in Form Flows
Form flows (multi-step forms) only supported a single level of steps. In
Symfony 8.2, a step can contain other steps, so you can define a hierarchy of
steps. When a step only exists to group other steps, use the new
createStepGroup() method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\Form\Flow\Type\NavigatorFlowType;
class FlightBookingType extends AbstractFlowType
{
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
{
$builder
->addStep(
$builder->createStepGroup('flights')
->addStep('outbound', OutboundFlightType::class)
->addStep('return', ReturnFlightType::class)
)
->addStep('passengers', PassengersType::class)
->addStep('payment', PaymentType::class);
$builder->add('navigator', NavigatorFlowType::class);
}
}
Group steps don't render any form, so the flow moves directly to their child
steps. You can also call setSkip() on a step to skip it and all its child
steps based on the submitted data. Nesting only changes the structure and the
navigation of the flow: all steps still map their data to the same underlying
object.
Reacting After Form Validation
The form is validated once, when the root form dispatches its POST_SUBMIT
event. Child forms dispatch that event before the root form, so a listener on a
child form could never know whether the form was valid. Symfony 8.2 adds a new
FormEvents::POST_VALIDATE event (form.post_validate), dispatched on each
form of the tree after the whole form has been validated:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PostValidateEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
class CaptchaType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->addEventListener(FormEvents::POST_VALIDATE, function (PostValidateEvent $event): void {
if (!$event->getForm()->getRoot()->isValid()) {
// reset the captcha because the form will be displayed again
}
});
}
}
This event is meant for reusable form types like the one above. For
application logic, keep checking isValid() in your controller after calling
handleRequest().
Symfony itself now uses this event to hash the password of PasswordType
fields that define the hash_property_path option. As a result, the
FormTypePasswordHasherExtension class and the registerPassword() and
hashPasswords() methods of PasswordHasherListener are deprecated and
will be removed in Symfony 9.0.
Transforming Submitted Arrays
When a form field that is neither compound nor multiple receives an array,
the form rejects it before dispatching the PRE_SUBMIT event. This prevents
you from transforming that data in a listener, which is a common need when
handling JSON payloads. Symfony 8.2 adds an allow_array_submission option
to run your listeners first:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
use Symfony\Component\Form\Event\PreSubmitEvent;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormEvents;
// the JSON payload contains: "tags": ["symfony", "php", "forms"]
$builder->add('tags', TextType::class, [
'allow_array_submission' => true,
]);
$builder->get('tags')->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void {
if (\is_array($tags = $event->getData())) {
$event->setData(implode(',', $tags));
}
}, 10);
The priority of the listener must be above 0 so it runs before the
listeners of the form type. If the data is still an array after the listeners
run, the form fails with the same error as before. The option is false by
default because, when you enable it, all the PRE_SUBMIT listeners of that
field must be ready to receive arrays.
Better Attributes of the Form Element
The form_attr option adds the HTML form attribute to a field, so you
can render it outside of the <form> element. However, this attribute
referenced the id of the <div> that wraps the fields instead of the id of
the <form> element, so the option never worked. In Symfony 8.2, the
<form> element renders an id when some field uses form_attr:
1 2 3 4
<form name="post" method="post" id="form_post">
<div id="post">
<input id="post_title" name="post[title]" form="form_post">
<!-- ... -->
Forms that don't use form_attr render the same HTML as before. To choose a
different id, set the id attribute in the attr option of the root form.
If your custom form theme overrides the form_start block, update it to
render the new form_id variable.
In addition, you can now override or remove the name attribute of the
<form> element with the attr option, like any other attribute:
1
{{ form_start(form, {attr: {name: false}}) }}
Per-Choice Help and Sorted Choices
Form fields can display a help message with the help option, but in a
ChoiceType field the help applied to the whole field. Symfony 8.2 adds a
choice_help option to describe each choice. It works like choice_label,
so it accepts an array, a callable or a property path:
1 2 3 4 5 6 7
$builder->add('plan', ChoiceType::class, [
'choices' => $plans,
'choice_label' => 'name',
// calls the getDescription() method of each Plan object
'choice_help' => 'description',
'expanded' => true,
]);
Each radio button or checkbox now renders its help message, linked to the input
with an aria-describedby attribute for accessibility. The help is translated
with choice_translation_domain, like the labels. Collapsed <select>
fields don't render it, because <option> elements can't contain other elements.
In addition, the new sort_choices option sorts the choices alphabetically by
their translated label, using the rules of the current locale. This is useful
for EnumType fields, whose cases are listed in their declaration order:
1 2 3 4
$builder->add('sport', EnumType::class, [
'class' => Sport::class,
'sort_choices' => true,
]);
Sorting uses the Collator class of the intl PHP extension, so accents,
case and numbers are handled correctly (e.g. Level 2 goes before Level 10).
Choice groups and preferred choices are sorted too.