Dynamic Form Events

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Alright, here's the issue and it is super technical. If we change the Location from "Near a Star" to "Solar System", even if we "hack" the specificLocationName field so that it submits the value "Earth", it doesn't work! It fails validation!

This is a real problem, because, in a few minutes, we're going to add JavaScript to the page so that when we change location to "The Solar System", it will dynamically update the specificLocationName dropdown down to be the list of planets. But for that to work, our form system needs to be smart enough to realize - at the moment we're submitting - that the location has changed. And then, before it validates the ChoiceType, it needs to change the choices to be the list of planets.

Don't worry if this doesn't make complete sense yet - let's see some code!

Adding an Event Listener

There's one piece of the form system that we haven't talked about yet: it has an event system, which we can use to hook into the form loading & submitting process.

At the end of the form, add $builder->get('location')->addEventListener() and pass this FormEvents::POST_SUBMIT. This FormEvents class holds a constant for each "event" that we can hook into for the form system. Pass a callback as a second argument: Symfony will pass that a FormEvent object.

Let's dd() the $event so we can see what it looks like.

... lines 1 - 26
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 29 - 68
$builder->get('location')->addEventListener(
FormEvents::POST_SUBMIT,
function(FormEvent $event) {
dd($event);
}
);
}
... lines 76 - 117

But before we check it out, two important things. First, when you build a form, it's actually a big form tree. We've seen this inside of the form profiler. There's a Form object on top and then each individual field below is itself a full Form object. The same is true with the "form builder": we normally just interact with the top-level $builder by adding fields to it. When we call $builder->add(), that creates another "form builder" object for that field, and you can fetch it later by saying $builder->get().

Second, we're attaching the event to only the location field - not the entire form. So, when the form submits, Symfony will call this function, but the $event object will only have information about the location field - not the entire form.

Let's actually see this! Refresh to re-submit the form. There it is! The FormEvent contains the raw, submitted data - the solar_system string - and the entire Form object for this one field.

Dynamically Updating the Field

This gives us the hook we need: we can use the submitted data to dynamically change the specificLocationName field to use the correct choices, right before validation occurs. Actually, this hook happens after validation - but we'll use a trick where we remove and re-add the field, to get around this.

To start, create a new private function called setupSpecificLocationNameField(). The job of this function will be to dynamically add the specificLocationName field with the correct choices. It will accept a FormInterface - we'll talk about that in a minute - and a ?string $location, the ? part so this can be null.

... lines 1 - 81
private function setupSpecificLocationNameField(FormInterface $form, ?string $location)
{
... lines 84 - 102
}
... lines 104 - 145

Inside, first check if $location is null. If it is, take the $form object and actually ->remove() the specificLocationName field and return. Here's the idea: if when I originally rendered the form there was a location set, then, thanks to our logic in buildForm(), there will be a specificLocationName field. But if we changed it to "Choose a location", meaning we are not selecting a location, then we want to remove the specificLocationName field before we do any validation. We're kind of trying to do the same thing in here that our future JavaScript will do instantly on the frontend: when we change to "Choose a location" - we will want the field to disappear.

... lines 1 - 81
private function setupSpecificLocationNameField(FormInterface $form, ?string $location)
{
if (null === $location) {
$form->remove('specificLocationName');
return;
}
... lines 89 - 102
}
... lines 104 - 145

Next, get the $choices by using $this->getLocationNameChoices() and pass that $location. Then, similar to above, if (null === $choices) remove the field and return. This is needed for when the user selects "Interstellar Space": that doesn't have any specific location name choices, and so we don't want that field at all.

... lines 1 - 81
private function setupSpecificLocationNameField(FormInterface $form, ?string $location)
{
... lines 84 - 89
$choices = $this->getLocationNameChoices($location);
if (null === $choices) {
$form->remove('specificLocationName');
return;
}
... lines 97 - 102
}
... lines 104 - 145

Finally, we do want the specificLocationName field, but we want to use our new choices. Scroll up and copy the $builder->add() section for this field, paste down here, and change $builder to $form - these two objects have an identical add() method. For choices pass $choices.

... lines 1 - 81
private function setupSpecificLocationNameField(FormInterface $form, ?string $location)
{
... lines 84 - 97
$form->add('specificLocationName', ChoiceType::class, [
'placeholder' => 'Where exactly?',
'choices' => $choices,
'required' => false,
]);
}
... lines 104 - 145

Nice! We created this new function so that we can call it from inside of our listener callback. Start with $form = $event->getForm(): that gives us the actual Form object for this one field. Now call $this->setupSpecificLocationNameField() and, for the first argument, pass it $form->getParent().

... lines 1 - 27
public function buildForm(FormBuilderInterface $builder, array $options)
{
... lines 30 - 69
$builder->get('location')->addEventListener(
FormEvents::POST_SUBMIT,
function(FormEvent $event) {
$form = $event->getForm();
$this->setupSpecificLocationNameField(
$form->getParent(),
... line 76
);
}
);
}
... lines 81 - 145

This is tricky. The $form variable is the Form object that represents just the location field. But we want to pass the top level Form object into the function so that the specificLocationName field can be added or removed from it.

The second argument is the location itself, which will be $form->getData(), or $event->getData().

... lines 1 - 73
$this->setupSpecificLocationNameField(
$form->getParent(),
$form->getData()
);
... lines 78 - 145

Okay guys, I know this is craziness, but we're ready to try it! Refresh to resubmit the form. It saves. Now change the Location to "Near a Star". In a few minutes, our JavaScript will reload the specificLocationName field with the new options. To fake that, inspect the element. Let's go copy a real star name - how about Sirius. Change the selected option's value to that string.

Hit update! Yes! It saved! We were able to change both the location and specificLocationName fields at the same time.

And that means that we're ready to swap out the field dynamically with JavaScript. But first, we're going to leverage another form event to remove some duplication from our form class.

Leave a comment!

  • 2020-06-22 weaverryan

    Hey Igor Wnęk!

    Oh boy! These dynamic forms can be *complex*. First, I really don't know why it's not working for you. My guess is that, because you're adding the 2nd field (process) in POST_SUBMIT, when you try to look for that 2nd field, it doesn't *appear* to be there. That's just a wild guess based on what you're telling me :/.

    One possible workaround is to *always* have the three fields (including process), just make the 2nd and 3rd empty (and hidden via JS) until you're reading to show them. Then, $builder->has(process') might return true. Or, it might just create more problems 🙃

    Another fix might be to *always* add this POST_SUBMIT listener - do NOT bit it in an if statement. Instead, put the if statement INSIDE of the callback - e.g. $event->getForm->has('process'). That might be enough to fix things.

    Good luck and let me know how it goes!

    Cheers!

  • 2020-06-19 Igor Wnęk

    Hello everyone!

    I have a question about dynamic forms.
    I want to allow user to select some things that require three select inputs at all. But it must work like:
    - user see one select input, he pick one value,
    - js use ajax to submit data and load second select input and show it to a user with items that required data from the first select (this is the stage that works well for me using POST_SUBMIT listener)
    - user pick value from second select input and now again js should submit data from select 1. and 2. and should load and show third select input that require data from 1. and 2.
    And here is the problem. I have tried to use POST_SUBMIT again like this:

    if ($builder->has('process')) {
    $builder->get('process')->addEventListener(
    FormEvents::POST_SUBMIT,
    static function (FormEvent $event) use ($machiningOperationFormModifier) {
    // $machiningOperationFormModifier is just closure that add new field to the form
    $process = $event->getForm()->getData(); // this is the value from 'second' select
    $frameworkProcess = $event->getForm()->getParent()->getData(); // this is the value from 'first' select
    $machiningOperationFormModifier($event->getForm()->getParent()->getParent(), $process, $frameworkProcess);
    }
    );
    }


    but above code is never entered by php (I have dumped).

    Is it possible to make such complicated form for working well?

  • 2020-03-02 Diego Aguiar

    > But maybe sometimes its harder because you dont know tool well

    That's so true, not knowing how a tool works makes things harder but it should only happen in the beginning, as you keep working with it, you get more proficient, and you should feel the benefits of using the tool

  • 2020-03-01 Coder

    yes, I also think, that tools should do job easier, not harder. But maybe sometimes its harder because you dont know tool well. Otherwise, for me it looks like using symfony framework should not be an option :) when I worked with codeigniter and laravel 4, it felt much easier than symfony :) But maybe if I would not use things like forms and other features, then it could be similar to laravel by dificulty.

  • 2020-02-06 weaverryan

    Hey Krzysztof Kujawski!

    That's not crazy at all - it's the most popular reason that you need to get into all this "form events" mess :). What's curious is that (really) you're doing the *exact* same thing I'm doing in this tutorial - you're pre-populating the "correct" choice(s) for a drop-down menu on submit so that you do not get the "This value is not valid" from the field. The only big difference is that I'm using ChoiceType and you're using EntityType - but these are effectively the same thing.

    So, you're getting the:

    > This value should not be blank

    validation error. I can suggest 2 things:

    1) Check out the profiler for your form AFTER the submit (so when you're looking at the page with the validation error) - specifically look at the part of the form profiler for your "manager" field. What does the "submitted data" say for that field? It *seems* like the field thinks that there is *no* submitted data. I would also be interested in what dump($form['manager']->getData()) looks like after "handling the form" inside your controller. Is that null?

    2) Or... you can just try a different strategy :p. What you're doing is certainly not a wrong strategy. But since you are not *truly* ever using the "drop down" functionality of the EntityType, I would probably have done this initially as a "hidden" input field (which select2 populates) and then used a custom data transformer to go from entity -> id and id -> entity. The downside is that you're not re-using the built-in "entity transformation" that EntityType gives you... but the positive is that dealing with a "hidden" input field is simpler: no need to mess around with events. In your data transformer, you would simply need to take the submitted "id" and query for a manager object. If there was some restriction where only some users can be selected as "managers" and a "bad" user hacked the HTML and submitted a manager id that should *not* be used, simply detect that in your data tranformer.

    Let me know what you do and how it goes!

    Cheers!

  • 2020-02-04 Krzysztof Kujawski

    The "why"

    I have about 1000 users in my database. I've used select2 before and started to using it also in this case. I can't load every user to select (there are so many) on form initialization, so I came up with this solution (maybe it is crazy, but seems to me to be the most obvious one).
    I have a form for project. Each project has relation to manager (User) which is required field (verified by Assert\NotBlank() in Entity). So I choose EntityType. On "new" form I initialize empty (only with placeholder) field "manager", then it is populated by select2 with Ajax with one, selected option.
    Then - with no event listener - validator throws error 'invalid value' (which is pretty obvious - choices option on initialization was empty - so there is no valid values).

    I came up with idea that I use EventListener to populate choices on *_SUBMIT event. I'm getting event value for manager, then querying for option with UserRepository and bringing choices to field with setupManagerField function.
    Now validator throws 'empty value error'. Choices are populated by one item on form render, profiler shows that submitted value exist, but still - empty value error.

  • 2020-02-03 weaverryan

    Hey Krzysztof Kujawski!

    Hmm, I'm not sure - this stuff is so complex - it's very easy to get some little detail wrong :/. Can you tell me more about the "why" behind what you're building? Like why there is a drop-down field with one item that is filled in dynamically with JS? I don't know the exact reasons behind what you're doing, but one other way to accomplish this (since it's not a "true" drop-down - you are always just populating it for the user with one option) is to make manager a HiddenType field. On the front-end, you an show a select visually, of course, but behind the scenes, you will truly be setting the id onto the value of that hidden field. Then, the only magic you'll need on submit is a custom data transformer on that field which will transform from the Manager object to its id and its id back to the Manager object. The whole thing might give you less trouble :).

    Cheers!

  • 2020-01-30 Krzysztof Kujawski

    What about changing same field?
    I have EntityType field rendered as select, which is intentionally empty on start. Then I'm using select2 to populate it with one option.
    Next - in EventListener I'm changing choices list - to just one - the same that was sent from select on form submit.

    And problems start here.

    After populating choices with one option - Invalid value error dissapear, but 'This value should not be blank' firing.


    private function setupManagerField(FormInterface $form, ?int $manager)
    {
    $choices = $this->userRepository->findBy(['id' => $manager]);
    $form->add('manager', EntityType::class, [
    'class' => User::class,
    'choice_label' => function (User $user) {
    return sprintf('%s (%s)', $user->getFirstName(), $user->getUsername());
    },
    'choices' => $choices,
    'attr' => ['class' => 'select2-dropdown']
    ]);

    $form->get('manager')->setData($this->userRepository->find($manager));
    }

    Am I doing something wrong or this is not possible?

  • 2020-01-27 Diego Aguiar

    It should be easy, whenever you feel like it's just so complex, you should use something else :)
    Tools are for making our job easier not harder

    Cheers!

  • 2020-01-25 Coder

    I think its more clear, thanks :) Not sure if I remember when I need it :D

  • 2020-01-22 Diego Aguiar

    Hey Coder

    Haha, yes, this is madness, and yes, in this situation you may want to use something different than Symfony Forms. About the differences between the FormBuilder and FormInterface is that the FormBuilder is who creates instances of FormInterfaces. So, when you are inside a FormType, you will be working with a FormBuilder instance. In this case the method setupSpecificLocationNameField() is called inside an EventForm, and that event gets called after the building time, that's why you have to work directly with a FormInterface instance. I hope I didn't leave you more confused than you actually were :p

    Cheers!

  • 2020-01-19 Coder

    This is real crazyness :) I dont get how do know when we need builder, and when we need form object. For example $builder->get('location') why not $form->get('location') ? :) and isnt this one of example of when to not use symfony form ?:)

  • 2019-06-07 Victor Bocharsky

    Hey Nicoschwartz!

    Awesome! Thank you for the feedback that you were able to solve it. If you have an extra solution that was not covered in our videos - it would be great if you were able to share it in your comment - it might help others a lot.

    Also, I think this thread might me interesting for people as well: https://symfonycasts.com/sc... - just wanted to add a cross link to it.

    Cheers!

  • 2019-06-07 nicoschwartz

    problem solved :)

  • 2019-06-06 nicoschwartz

    Thank you but I think I might have missed something.
    When I try to do $builder->get('field1')->addEventListener(...)
    I get an error The child with the name "field1" does not exist.since it is only added with the EventListener $builder->addEventListener(FormEvents::PRE_SET_DATA [...]{

  • 2019-06-06 Victor Bocharsky

    Hey Nicoschwartz,

    Take a look at the next chapter: https://symfonycasts.com/sc... - I think it might help you to implement this.

    Cheers!

  • 2019-06-06 nicoschwartz

    Hello !
    I'm struggling with a form that has both a dynamic field1 (depending on the connected user) for which I use
    $builder->addEventListener(FormEvents::PRE_SET_DATA [...]
    and a field2 that depends on the value selected in field1 like the example of this page.
    How can I handle that ?