Dynamic Forms Without a Page Reload

Dependent selects and inline validation built from form events and a Live Component, with the server still deciding what is valid.

Syntax$builder->get('country')->addEventListener(FormEvents::POST_SUBMIT, $callback);

A dependent select is two fields where the second one's options depend on the first: choose a country, then choose one of that country's states. The options cannot be decided in the browser. The browser does not have the list, and must not be trusted with it.

Form events do the work

A form type can rebuild part of itself in response to events. FormEvents::POST_SET_DATA fires when the form receives its initial data, so on first render the state field is built from the country already on the object. FormEvents::POST_SUBMIT on the country child fires once that field has been submitted and converted to an object, so the state field is rebuilt from the value that actually arrived. That much already works with an ordinary page reload, and it is the part that matters.

Removing the reload

Wrap the form in a Live Component using ComponentWithFormTrait. The trait exposes the submitted values as a writable property called formValues, and provides submitForm(), getForm() and resetForm(). Putting data-model="on(change)|*" on the form element sends every field when it changes; the component re-creates the form, submits those values and re-renders. The listeners run as they always did.

The server stays the authority

None of this makes the client's answer trustworthy. The final submit runs the same validation. A choice field only accepts values among the choices it was built with, so an invented state is rejected even though no JavaScript stopped it being sent. Inline validation is feedback, not enforcement.

Example

Example · php
<?php

// src/Form/AddressType.php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Address;
use App\Entity\Country;
use App\Entity\State;
use App\Repository\StateRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PostSetDataEvent;
use Symfony\Component\Form\Event\PostSubmitEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class AddressType extends AbstractType
{
    public function __construct(private readonly StateRepository $states)
    {
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('country', EntityType::class, ['class' => Country::class]);

        $addState = function (FormInterface $form, ?Country $country): void {
            $form->add('state', EntityType::class, [
                'class' => State::class,
                'choices' => null === $country ? [] : $this->states->findByCountry($country),
            ]);
        };

        // First render: build the state field from the data being set.
        $builder->addEventListener(FormEvents::POST_SET_DATA, function (PostSetDataEvent $event) use ($addState): void {
            $address = $event->getData();

            $addState($event->getForm(), $address?->getCountry());
        });

        // Each submit: rebuild it from the country that was sent. The
        // listener is on the child, so it changes the child's parent.
        $builder->get('country')->addEventListener(FormEvents::POST_SUBMIT, function (PostSubmitEvent $event) use ($addState): void {
            $addState($event->getForm()->getParent(), $event->getForm()->getData());
        });
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Address::class]);
    }
}

When to use it

  • Narrow a state select to the chosen country without reloading the page.
  • Tell the user a chosen slug is already taken while they are still typing it.
  • Add and remove rows in an embedded collection of fields in place.
  • Keep each field's validation message beside it while the server decides what is valid.

More examples

The Live Component wrapper

ComponentWithFormTrait keeps the submitted values in formValues and rebuilds the form from them on each render, which is why instantiateForm() exists instead of the form being a constructor argument. submitForm() is what makes isValid() meaningful, so call it before reading getData(). Returning a redirect from a live action tells the browser to navigate, which is how a component finishes a workflow and hands the user to a normal page.

Example · php
<?php

// src/Twig/Components/AddressForm.php

declare(strict_types=1);

namespace App\Twig\Components;

use App\Entity\Address;
use App\Form\AddressType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;

// Extending AbstractController here is only for createForm() and
// redirectToRoute(); the class is a component, not a route.
#[AsLiveComponent]
final class AddressForm extends AbstractController
{
    use ComponentWithFormTrait;
    use DefaultActionTrait;

    #[LiveProp]
    public ?Address $initialFormData = null;

    // Called on every render, so AddressType's listeners run every time
    // and the state field is always built from the current country.
    protected function instantiateForm(): FormInterface
    {
        return $this->createForm(AddressType::class, $this->initialFormData);
    }

    #[LiveAction]
    public function save(EntityManagerInterface $entityManager): Response
    {
        $this->submitForm();

        $address = $this->getForm()->getData();

        $entityManager->persist($address);
        $entityManager->flush(); // persist() schedules, flush() writes

        return $this->redirectToRoute('app_address_show', ['id' => $address->getId()]);
    }
}

Rendering the form inside the component

data-model="on(change)|*" binds the whole form: changing the country select sends every field's current value and triggers a re-render, which is what repopulates the state select. The :prevent on the action stops the browser's own submit so the live action handles it. form_row() already prints each field's validation errors, so inline errors need no extra markup. Disabling the button while a request is in flight prevents a double submit.

Example · twig
{# templates/components/AddressForm.html.twig #}
<div {{ attributes }}>
    {{ form_start(form, { attr: {
        'data-model': 'on(change)|*',
        'data-action': 'live#action:prevent',
        'data-live-action-param': 'save',
    } }) }}

        {# Longhand: one row at a time, so you choose the order and #}
        {# can put markup between fields.                           #}
        {{ form_row(form.country) }}
        {{ form_row(form.state) }}

        {# Shorthand: renders every field not yet rendered above, #}
        {# which is nothing here. Useful as a safety net when a   #}
        {# listener adds a field you did not name.                #}
        {{ form_widget(form) }}

        <button data-loading="addAttribute(disabled)">Save</button>
    {{ form_end(form) }}
</div>

Validation without a form type

ValidatableComponentTrait gives a component validate() and the _errors variable in its template, where {% if _errors.has('email') %}{{ _errors.get('email') }}{% endif %} prints the message next to the field. Reach for this when the interaction is one or two fields and a form type would be more structure than the job needs. The validation still happens on the server inside the action, so the rule is in one place whether the request came from this component or from anywhere else.

Example · php
<?php

// src/Twig/Components/SubscribeBox.php

declare(strict_types=1);

namespace App\Twig\Components;

use App\Service\MailingList;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;

#[AsLiveComponent]
final class SubscribeBox
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    // Constraints sit on the property, so there is no form type at all.
    #[LiveProp(writable: true)]
    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email = '';

    public function __construct(private readonly MailingList $mailingList)
    {
    }

    #[LiveAction]
    public function subscribe(): void
    {
        // Throws when a constraint fails. The component re-renders and the
        // messages are available in the template as _errors.
        $this->validate();

        $this->mailingList->add($this->email);
    }
}

Discussion

  • Be the first to comment on this lesson.