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

Sébastien JEAN
Contributed by Sébastien JEAN in #65928

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

Yonel Ceruto
Contributed by Yonel Ceruto in #63734

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

Sébastien Alfaiate
Contributed by Sébastien Alfaiate in #47210 and #65794

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

Nicolas Grekas
Contributed by Nicolas Grekas in #65515

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

Romain Monteil Julien Robic
Contributed by Romain Monteil and Julien Robic in #48013 and #63944

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

Pascal CESCON Sébastien JEAN
Contributed by Pascal CESCON and Sébastien JEAN in #63819 and #66227

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.

Published in #Living on the edge