Real-Time Validation & Dependent Form Fields
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeFor day 28, I want to show you one of the most common ways that people are using Live Components: forms. Because Live Components have this power to reload as you type, they give us interesting possibilities with forms, like real-time validation! So here's today's goal: convert the Voyage form into a Live Component and see some cool real-time validation for ourselves!
We already have a controller that takes care of creating the Voyage form and handles this submit. What we're going to do is wrap the frontend part of the form inside a Live Component so that as we type, it re-renders. But ultimately, when we save, it'll save like normal through the controller.
Moving the Form into a Twig Component
For step one, forget about Live Components: let's just convert the form rendering into a Twig Component. In this case, I know we're going to need a PHP class, so create a new one called VoyageForm and make it a Twig Component with #[AsTwigComponent]:
| // ... lines 1 - 2 | |
| namespace App\Twig\Components; | |
| // ... lines 4 - 5 | |
| use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
| class VoyageForm | |
| { | |
| // ... line 11 | |
| } |
Perfect! The form itself lives in templates/voyage/_form.html.twig and uses a form variable, which we'll need to pass into the Twig component.
In the VoyageForm class, add a public property for this: public FormView $form, because FormView is the object type for the form variable:
| // ... lines 1 - 4 | |
| use Symfony\Component\Form\FormView; | |
| // ... lines 6 - 7 | |
| class VoyageForm | |
| { | |
| public FormView $form; | |
| } |
Next, in templates/components/, create the component template: VoyageForm.html.twig. Copy the entire form, paste it here:
| {{ form_start(form) }} | |
| {{ form_widget(form) }} | |
| <twig:Button | |
| formnovalidate | |
| variant="success" | |
| class="hover:animate-wiggle" | |
| > | |
| {{ button_label|default('Save') }} | |
| </twig:Button> | |
| {{ form_end(form) }} |
And then in _form.html.twig, it's simple: <twig:VoyageForm />:
| <twig:VoyageForm :form="form" /> |
And over at the browser... bah! We get:
Variable
formdoes not exist.
Let's think about this. We do have a public property in the component class called form... so we should have a local variable with that name. But, the property is uninitialized because I forgot to pass in that value. My bad! Pass :form="form" - using : so that the value - form - is Twig code: that's the form variable:
| <twig:VoyageForm :form="form" /> |
And now... got it! Before we keep going, inside the template, remember to render the attributes variable. The easiest is to wrap this in a div and say {{ attributes }}. I'll put the closing tag... then indent the entire form:
| <div {{ attributes }}> | |
| {{ form_start(form) }} | |
| // ... lines 3 - 11 | |
| {{ form_end(form) }} | |
| </div> |
So the form rendering is now a Twig component. But to give it behavior, we need a Live Component.
LiveComponent & Symfony Forms
Let's think. After changing any field, I want a Live Component to collect the value of every field and send them to the Live Component system via an Ajax call. The Live Component will then submit these values into the form object and rerender the template.
Using Symfony forms with Live Components is a bit more of a complex use-case than the normal case of Live components: where we create some public properties and make them writable.
Fortunately, Live Component ships with a trait to help. In VoyageForm, first, convert this to a Live Component by saying #[AsLiveComponent] then using the DefaultActionTrait:
| // ... lines 1 - 9 | |
| use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | |
| // ... line 11 | |
| use Symfony\UX\LiveComponent\DefaultActionTrait; | |
| class VoyageForm extends AbstractController | |
| { | |
| use DefaultActionTrait; | |
| // ... lines 18 - 27 | |
| } |
Next, because we want to bind this component to a form object, use ComponentWithFormTrait. When we do that, we don't need this public form property anymore because that lives inside the trait:
| // ... lines 1 - 10 | |
| use Symfony\UX\LiveComponent\ComponentWithFormTrait; | |
| // ... lines 12 - 13 | |
| class VoyageForm extends AbstractController | |
| { | |
| use DefaultActionTrait; | |
| use ComponentWithFormTrait; | |
| // ... lines 20 - 27 | |
| } |
However, this trait does require one new method. Go to "Code"->"Generate" - or Cmd+N on a Mac - and implement the one we need: instantiateForm():
| // ... lines 1 - 7 | |
| use Symfony\Component\Form\FormInterface; | |
| // ... lines 9 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 19 | |
| protected function instantiateForm(): FormInterface | |
| { | |
| // ... lines 22 - 26 | |
| } | |
| } |
This might look strange at first. But remember, as we change fields in our form, the form values will be sent via Ajax back to our Live component... which then needs to submit them into the form object so it can re-render. This means that, during the Ajax call, our Live Component needs to be able to create our form object. To do that, it calls this method.
To get the logic for this, in VoyageController, all the way at the bottom, copy the guts of createVoyageForm()... then paste them here. Hit okay to add the two use statements:
| // ... lines 1 - 4 | |
| use App\Entity\Voyage; | |
| use App\Form\VoyageType; | |
| // ... lines 7 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 19 | |
| protected function instantiateForm(): FormInterface | |
| { | |
| $voyage = $voyage ?? new Voyage(); | |
| return $this->createForm(VoyageType::class, $voyage, [ | |
| 'action' => $voyage->getId() ? $this->generateUrl('app_voyage_edit', ['id' => $voyage->getId()]) : $this->generateUrl('app_voyage_new'), | |
| ]); | |
| } | |
| } |
There's... just one problem: the createForm() and generateUrl() methods don't exist here! But I haven't told you about a crazy, cool thing: Live Components are Symfony controllers in disguise! And this means we can extend AbstractController:
| // ... lines 1 - 6 | |
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |
| // ... lines 8 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 27 | |
| } |
That's totally allowed and gives us access to all the shortcuts we know and love.
Ok, showtime! Move over. When I type, nothing happens. In this case, Live Components waits for the field to change... so it waits for us to move off of the field. As soon as we do, we'll see an Ajax request fire down here. Watch. Boom! See it? That sent the data back, submitted the form and re-rendered the form.
To prove this, clear out the field and hit tab. A validation error! That's coming from Symfony and the normal form validation rendering! Type something again, tab, it goes away. The best part? The planet field down here is also required thanks to Symfony's validation constraints. But the Live Component system is smart: it knows that the user hasn't changed this field yet, so it shouldn't show the validation error. But if we do select a planet... then clear, when it re-renders, it shows the error.
Passing the Initial Form Data
This also works fine for the edit form. Hit edit & clear out a field.
Though, check out instantiateForm(). Hmm, we're always instantiating a new Voyage object: there's never a $voyage variable. We change a field, Live Components sends an Ajax request and, when it creates the form, it does it using a brand new Voyage object, not the existing Voyage object from the database.
And... that's probably okay... because it submits all the data onto it, and it renders correctly.
However, one thing you can do with Live components is submit the form directly into the Component object and handle the save logic there. We're not going to do that, but if we did, the Voyage object bound to the form would always be a new object... and it would always insert a new row into the database.
Passing in the Initial Form Data
So even though this works, it's a bit weird.
To tighten this up, we can store the existing Voyage object on the component and use that during form creation. Add a public ?Voyage $initialFormData property. Above this, to make the component system remember this value through all of its Ajax requests, add #[LiveProp]:
| // ... lines 1 - 10 | |
| use Symfony\UX\LiveComponent\Attribute\LiveProp; | |
| // ... lines 12 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 18 - 20 | |
| public ?Voyage $initialFormData = null; | |
| // ... lines 23 - 31 | |
| } |
This is now a non-writable prop that our component will keep track of. And yes, it's non-writable: the user changes the form data directly, not this property. This is just here to help us create the form object on each Ajax call.
Below, change this to $voyage equals $this->initialFormData, else new Voyage():
| // ... lines 1 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 18 - 20 | |
| public ?Voyage $initialFormData = null; | |
| protected function instantiateForm(): FormInterface | |
| { | |
| $voyage = $this->initialFormData ?? new Voyage(); | |
| // ... lines 27 - 30 | |
| } | |
| } |
Finally, pass in the initialFormData by saying :initialFormData="voyage", which is a Twig variable that we already have:
| <twig:VoyageForm :form="form" :initialFormData="voyage" /> |
So we won't notice a difference, but when we hit edit and change a field, that Ajax request now creates a Form object bound to this existing Voyage object.
That got a bit technical, but let's zoom out. By rendering out form through a Live Component, we get real-time validation for free! That's cool.
Dependent Form Fields
We're almost out of time, but I think we can tackle one more form problem today. In fact, maybe the most painful form problem in all of Symfony.
On this form, if the planet is not in our solar system, I want to render a new dropdown for an optional wormhole upgrade. This is the classic dependent form field problem. In Symfony, it's hard because we need to leverage form events. On the frontend it's hard too! Historically, we needed to write JavaScript to trigger an Ajax call to re-render the form.
But... that second part is now taken care of! Live Components is great at re-rendering the form when fields change. And the first part? Yea, there's a new library that makes that easy too!
It's called symfonycasts/dynamic-forms... created by us because this problem drove me absolutely crazy. Hat tip to Symfony dev Ben Davies who really cracked the code on this.
Copy the composer require line, spin over, and run that:
composer require symfonycasts/dynamic-forms
Using this is really pleasant. Find the form class: src/Form/VoyageType.php. The library uses decoration. At the top, say $builder equals new DynamicFormBuilder() and pass in $builder:
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| // ... lines 20 - 52 |
This DynamicFormBuilder has the same methods as the original, but one extra: addDependent(). But before we use it, comment-out the 'autocomplete' => true:
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| $builder | |
| // ... lines 21 - 24 | |
| ->add('planet', null, [ | |
| // ... lines 26 - 27 | |
| //'autocomplete' => true, | |
| ]) | |
| // ... lines 30 - 41 | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
There's a bug with the autocomplete system and Live Components. It should be fixed soon, but I don't want it to get in the way.
Anyway, the addDependent() method takes three arguments. The first is the name of the new field: wormholeUpgrade. The second is an array of fields that this field depends on. In this case, that's only planet. The final argument is a callback function and its first argument will always be a DependentField object. We'll see how that's used in a minute. Then, this will receive the value of every field that it depends on. Because we depend only on planet, the callback will receive that as an argument: ?Planet $planet:
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| $builder | |
| // ... lines 21 - 24 | |
| ->add('planet', null, [ | |
| // ... lines 26 - 27 | |
| //'autocomplete' => true, | |
| ]) | |
| ->addDependent('wormholeUpgrade', ['planet'], function (DependentField $field, ?Planet $planet) { | |
| // ... lines 31 - 40 | |
| }) | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
Inside, if we don't have a planet - because the user hasn't selected one yet or the planet is in the Milky Way, just return. And yes, I borked up my space science: I meant for this to be isInOurSolarSystem() - not the milky way. Forgive me Data!
Anyway, because we're returning, there won't be a wormholeUpgrade field at all. Else, add one with $field->add(). This method is identical to the normal add() method except that we don't need to pass the name of the field... because we already pass it earlier. So skip straight to ChoiceType::class... then the options with choices set to an array of "Yes" for true, and "No" for false:
| // ... lines 1 - 7 | |
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | |
| // ... lines 9 - 14 | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| // ... line 19 | |
| $builder | |
| // ... lines 21 - 29 | |
| ->addDependent('wormholeUpgrade', ['planet'], function (DependentField $field, ?Planet $planet) { | |
| if (!$planet || $planet->isInMilkyWay()) { | |
| return; | |
| } | |
| $field->add(ChoiceType::class, [ | |
| 'choices' => [ | |
| 'Yes' => true, | |
| 'No' => false, | |
| ], | |
| ]); | |
| }) | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
Done! Go check out the result. Refresh, edit and change to a planet that's not in our system. There it is! The field popped into existence! If we go back to a planet that is in our solar system... gone! And... the field saves just fine. When we edit the voyage, the form starts with it. It just works!
Ok, we're nearly at the end of our 30-day journey! Tomorrow, it's time to talk about how we can test our beautiful new frontend features.
15 Comments
Has the bug mentioned at 9:25 been resolved? That use case is exactly what I'm currently trying to achieve and could have used some guidance.
Hi @Seriousedy
Sorry for the late answer, as I see from git updates, that should be already fixed in
symfony/ux-autocompletepackage, however, we are not applying such fixed to course code. So it's up to you to test it 😉Cheers1
Hey Ryan. Thanks again for another great tutorial. I just updated an app with a lot of form events, Ajax calls, and custom stimulus controllers, because of many dependent form fields and many kind of complex CollectionType fields.
Now I got rid of a lot of boilerplate code, and I am very happy to use LiveComponents more in the future.
For me, as a more backend-focused full-stack developer, it is a very nice technology. And now we even got to choose between multiple technologies that do in this direction (live-components, turbo, htmx as the most prominent ones).
I am just left with one question.
I am using live-components in combination with "SymfonyCasts/dynamic-forms" and it works perfect. But with my old custom setup, I knew exactly when I dynamically added dependent form fields or added/removed new items of a CollectionType, and in these cases manually added css classes to animate the newly added items/rows, so that it is smoother from a ux perspective.
How can I react to new dependent rows/fields of my form that get added or removed, or the same for items of my LiveCollection?
I know (from the awesome docs) there are hooks like "render:finished" which I am already using to get the Trix editor working with live components, but I am not sure what is the best ans most easy way to detect newly added or removed items and to add my css classes to those. Any ideas?
Hey @TristanoMilano
Happy to hear about the stack you are using. That is a proof that we are working in the right direction! About your question, I think this section of docs should help you https://symfony.com/bundles/ux-live-component/current/index.html#javascript-component-hooks. espesially
loading.state:hooks, give a try =)Cheers!
Hi everyone, a great tool for dependent form fields - I'm thrilled :)
I have the problem that dependent fields are no longer recognized as mandatory fields. I work with Turbo and LiveComponents. Displaying and hiding the dependent fields works wonderfully. But the "new" dependent fields are not recognized as mandatory fields if they are displayed.
Is there a solution?
thank you so much! cheers :)
Hey @creativx007!
Sorry for the slow reply: the team kept this message for me and I've been missing!
Hmm. This "should" just come down to validation. I'd use the
Callbackconstraint: https://symfony.com/doc/current/reference/constraints/Callback.html. This would allow you to mark certain fields asNotBlankIF some other field has a certain value.Way late, but I hope this helps you or someone else.
Cheers!
Hello people form SC! I have a question on Real Time validation with Live Components. I have a form and validation is going ok. But as soon as i add a field with the DateType::class i get an error. The error occurs when i fill in an other field. But I expect that the DateTime field doesn't throw an error because i didn't changed it yet.
I tried to change the DateType field to the default values but the error keeps popping. It doesn't happen on the other required fields (nullable=false in the entity class).
The formbuilder:
The fields in the entity class:
The error:
Hey @lexhartman!
I think you're close to the solution :). In this case, the error isn't really a validation error: it's a PHP error. When the
executedAtfield is submitted empty, it's converted tonulland the form system tries to call$yourObject->setExecutedAt(null). This happens even before validation is executed. I can see that your$executedAtproperty does allownull. But is it possible that yoursetExecutedAt()method does not allownull? It should look like this:Let me know if this was the issue :).
Cheers!
Yes! That was it 😅
Hi Ryan, I have a quick question on Real Time validation.
My form has a field which is a LiveCollectionType. I copied your example from the symfony ux demo page, and it works awesomely. However I'm having one weird issue with real time validation.
The validation on the embedded form type works perfectly, exactly how I would expect. However on the actual collection type, I have a constraint with a
new Count(min: 1). Now, if I remove the last embedded object from the collection (or don't add any in the first place), I don't get any real time validation on this field. I can live with that, because when I post the form I will get the error displayed then. When I add to the collection, the error goes away in real time. Perfect!But here's my issue. When this form has been posted (with zero in the collection causing the error), and now I press the 'add to collection' button, as I said the error will go away, however the embedded form is validating straight away, and since the field inside the collection is currently empty, I am getting the validation error. I would expect to NOT see a validation error until the user types something, deletes it, then tabs away. Every new embedded form added to the collection will have this issue, until the entire page is reloaded.
Did I miss something obvious?
Yo @Scott-D!
Sorry my slow reply! First, I don't use
LiveCollectionTypepersonally, so my knowledge is a bit limited. I really need to play with it more just so I can speak about it and, possibly, improve it if needed.Does this problem only happen if the entire form has been posted with zero items? Or does it happen after you submit the form in general (i.e. even if you have 2 items in the collection, after submitting, you will see the problem when you add a 3rd item)? Are you submitting via a
LiveAction?A key to making the auto-validation work is that LiveComponents keeps track of which fields have been modified by the user. Then, when you submit, it's careful to remove any errors from fields that have not yet been touched by the user. Otherwise, EVERY field would be validated the first time the live component re-renders, even though the user has only completed one field. There is even a
LivePropthat tracks which fields have been modified - https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L63However, when you submit the form, now you want EVERY field to be validated. In this case, we stop keeping track of
validatedFieldsand instead mark the entire form as needing validation. You can see that here - https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L88-L91 and here https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L158-L163 (depending on if you're submitting your form via a normal controller vs a LiveAction).So I think that's what's happening. You're submitting your form, so
isValidatedbecomes true and every field going forward is validated. I don't have an exact solution for you, but given this info, there may be a workaround you can do (and perhaps that workaround should be in the core library itself). For example, you may, before rendering (e.g. via a PreRender hook), grab your form object and manually clear the errors for any collection fields that look blank to you (see https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L271-L280 for some info on how you can clear an error).Let me know if that helps!
Cheers!
Hey Ryan, Thanks for such a detailed response!
Only in the first case. The 2nd case works perfectly, exactly as I would expect. If I edit an existing resource that has two items, then go to add a 3rd, there will be no immediate validation on that new embedded form.
So to reproduce:
As soon as I type into the required fields and tab away, those errors will go away in real time.
No, I'm not. I pretty much followed your example here to a T. In fact, I didn't even know about
LiveAction. I will definitely take a closer look at that.Thanks Ryan, I'll give this a shot!
Ryan,
I have these fields:
All works really well on a new form, but when I render the form for an 'edit', these three fields don't have their initial data. All my other form fields do, but not these three. So one would have to re-select these fields on an edit, even if they don't want to change them. I can not figure out why, any suggestions?
Yo @Brandon!
Hmm, interesting. This video also shows an edit form: and both of the fields DO have their value. What's interesting to me is this:
The key parts is: three fields. If truly all 3 fields are missing their data, that's significant because the first field -
selectedCategory- is not a dependent field: this is a plain, boring, normal field. So if this is empty on edit, double-check that the underlying data is, in fact set. This may not be a problem with the dynamic forms at all.Cheers!
Ryan,
I made an oversight, I wasn't saving the selectedCategory in the database, and I had no code to reverse back to it from the selectedGroup. Thank you for all your help, I wish you all the best. You have helped me so much in my journey.
"Houston: no signs of life"
Start the conversation!