Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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!

47
Login or Register to join the conversation
Default user avatar
Default user avatar Jan Kapciar | posted 1 year ago

Hi there,

here is a working example of 3 inputs based on PRE_SUBMIT events
Symfony 6, PHP 8

https://github.com/kapcus/s...

jan

1 Reply

Hey Jan,

Good job! Thanks for sharing this little example with other users! I think it might be useful for someone.

Cheers!

Reply
isGzmn Avatar
isGzmn Avatar isGzmn | posted 2 months ago | edited

I am trying to change the value of a given field when a form is submitted. The following example represents what I'm looking for with a PRE_SUBMIT Event:

`public function onPreSubmit(FormEvent $event) {
        $formData = $event->getData();
        $formData["emailGroups"] = "example@example.com";
        $event->setData($formData);
    }`

I get the correct value with $event->getData(); But when I try to set the new value it is set null.
Any idea or is this the correct way to update the data sent by the user?

Reply

Hey @isGzmn,

the code looks correct, here is my example from test code I created to test this solution

class TestEntityType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email')
            ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'preSubmit']);
        ;
    }
    
    public function preSubmit(FormEvent $event) {
        $data = $event->getData();
        $data['email'] = 'email@dd.d';

        $event->setData($data);
    }
//....
}

and here in my controller action

    public function new(Request $request, TestEntityRepository $testEntityRepository): Response
    {
        $testEntity = new TestEntity();
        $form = $this->createForm(TestEntityType::class, $testEntity);
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            dd($testEntity); // here we have updated value
            // ...
        }

        return $this->renderForm('test_entity/new.html.twig', [
            'test_entity' => $testEntity,
            'form' => $form,
        ]);
    }

Cheers!

Reply
ZakiZak Avatar

Hello,

I have an issue on a project. I have three entities (Country, City, Neighborhood).
Country has many cities and Cities is related to one Country. Same thing, City has many neighborhoods and Neghborhood is related to one City.
I created a form with Country Entity which get 3 fields (Choose Country, choose City, choose Neighborhood)

When i choose a Country, i can get his cities, that's works. But i can't do the same things between City and neighborhoods.
My goal is, when i choose a city, all his neighborhoods displayed.

This is my form :

$builder
            ->add('name', EntityType::class, [
                'class' => Country::class,
                'placeholder' => '',
                'label' => 'Votre pays'
            ])
            ->add('cities', ChoiceType::class, [
                'placeholder' => 'Villes (Choisir un pays)',
                'label' => 'Votre ville'
            ])
            ->add('neighborhoodname', ChoiceType::class, [
                'placeholder' => 'Quartier (Choisir une ville)',
                'label' => 'Votre quartier',
                'mapped' => false
            ])
            ->add('Valider', SubmitType::class)
        ;

        $formModifier = function(FormInterface $form, Country $country = null) {
            $cities = null === $country ? [] : $country->getCities();          
            
            $form->add('cities', EntityType::class, [
                'class' => City::class,
                'choices' => $cities,
                'choice_label' => 'name',
                'placeholder' => 'Villes (Choisir un pays)',
                'label' => 'Votre ville',
                'mapped' => false
            ]);

        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formModifier) {
                $data = $event->getData();

                $formModifier($event->getForm(), $data);
            }
        );

        $builder->get('name')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifier) {
                $country = $event->getForm()->getData();
                $formModifier($event->getForm()->getParent(), $country);
            }
        );

Thanks a lot and sorry for my english.

Reply

Hi ZakiZak!

Hmm. In theory, this should not be any more difficult, but I admit that I have not tried this before! So it could have some hidden surprises. What I would try first is pretty simple: duplicate $formModifier, name it $formModifierNeighborhoods and then update it to handle the city->neighborhood situation

$formModifier = function(FormInterface $form, City $city = null) {    
    $neighborhoods = null === $city ? [] : $city->getNeighborhoods();          
    
    $form->add('neighboorhoodname', EntityType::class, [
        'class' => Neighborhood::class,
        'choices' => $neighborhoods,
        // ...
    ]);
};

Now we need to make sure this is included in our 2 listeners. First, inside the PRE_SET_DATA listener, just call both modifiers. Note, I think there was a small bug in this part of the code you posted:

$builder->addEventListener(
    FormEvents::PRE_SET_DATA,
    function (FormEvent $event) use ($formModifier) {
        $data = $event->getData();

        // I tweaked these next 2 lines - this was the mistake I think I saw
        // it looked like you were passing the WHOLE data in, not just the Country
        $country = $data->getName();
        $formModifier($event->getForm(), $country);
        
        // new stuff here!
        $city = $data->getCities();
        $formModifierNeighborhoods($event->getForm(), $city);
    }
);

Finally, for the POST_SUBMIT listener, duplicate that, change name to cities then call the new modifier function:

$builder->get('cities')->addEventListener(
    FormEvents::POST_SUBMIT,
    function (FormEvent $event) use ($formModifier) {
        $city = $event->getForm()->getData();
        $formModifierNeighborhoods($event->getForm()->getParent(), $city);
    }
);

Let me know if that helps! Btw, the code was a bit weird because the field is called cities but the user is only selecting one city. But hopefully the solution is clear. Let me know if it works!

Cheers!

Reply
ZakiZak Avatar

Hi weaverryan,
Thank you for your help. I tried to do what you said but i have this error message :
App\Form\CountryType::App\Form\{closure}(): Argument #2 ($city) must be of type ?App\Entity\City, Doctrine\Common\Collections\ArrayCollection given, called in C:\xampp\htdocs\FormEvents\formEvents\src\Form\CountryType.php

I added $formModifierNeighborhoods

$formModifierNeighborhoods = function(FormInterface $form, City $city = null) {
            $neighborhoods = null === $city ? [] : $city->getNeighborhoods();          
            
            $form->add('cities', EntityType::class, [
                'class' => Neighborhood::class,
                'choices' => $neighborhoods,
                'choice_label' => 'name',
                // ...
            ]);

        }; 

The PRE_SET_DATA

$builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formModifier, $formModifierNeighborhoods) {
                $data = $event->getData();

                $country = $data->getName();
                $formModifier($event->getForm(), $country);

                $city = $data->getCities();
                $formModifierNeighborhoods($event->getForm(), $city);
            }
        );

The POST_SUBMIT

$builder->get('cities')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formModifierNeighborhoods) {
                $city = $event->getForm()->getData();
                $formModifierNeighborhoods($event->getForm()->getParent(), $city);
            }
        );

I don't know why it works for $country second argument but not for $city second argument.

Note : You are right. I will have change cities field to city

Reply

Hey ZakiZak!

Hmm. It seems like $data->getCities(); is returning a collection of City objects, instead of just one City object. On your top-level entity (whatever entity is "bound" to this form), does it relate to just ONE City or many City objects? It seems like, currently, it relates to MANY objects... but then in the form the user is selecting just one City object. Is that on purpose? Do you need to, perhaps, change the property on your top level entity from $cities to $city and change it to a singular relationship (e.g. from ManyToMany to ManyToOne)?

Cheers!

Reply
ZakiZak Avatar
ZakiZak Avatar ZakiZak | weaverryan | posted 7 months ago | edited

Hi weaverryan,

Thank you for your reply. I have only ManyToOne relationship between all my entities (Country, City, Neighborhood).

Country entity has many Cities & cities is attached to one Country (OneToMany)
City entity has many neighborhoods & neighborhoods is attached to one City (OneToMany)

The entity that is bound to this form is Country(CountryType) and his properties are the following :

#[ORM\Column(length: 150)]
private ?string $name = null;

#[ORM\OneToMany(mappedBy: 'country', targetEntity: City::class)]
private Collection $cities;    

For City :

#[ORM\Column(length: 150)]
private ?string $name = null;

#[ORM\ManyToOne(inversedBy: 'cities')]
private ?Country $country = null;

#[ORM\OneToMany(mappedBy: 'city', targetEntity: Neighborhood::class, orphanRemoval: true)]
private Collection $neighborhoods;

For Neighborhood :

    #[ORM\Column(length: 150)]
    private ?string $name = null;

    #[ORM\ManyToOne(inversedBy: 'neighborhoods')]
    #[ORM\JoinColumn(nullable: false)]
    private ?City $city = null;
Reply

Hey ZakiZak!

Ah, this helps! But I have one question:

The entity that is bound to this form is Country(CountryType) and his properties are the following :

This confused me, because you mentioned your form looked like this:

->add('name', EntityType::class, [
    'class' => Country::class,
    'placeholder' => '',
    'label' => 'Votre pays'
])

Having a name field in your form makes sense, because Country has a name property. But it doesn't make sense for this to be an EntityType... because the name is a string. Something isn't quite making sense here...

The error from earlier is somewhat related to this question:

App\Form\CountryType::App\Form{closure}(): Argument #2 ($city) must be of type ?App\Entity\City, Doctrine\Common\Collections\ArrayCollection given, called in C:\xampp\htdocs\FormEvents\formEvents\src\Form\CountryType.php

Currently, your form is set up so that the user is selecting ONE city. The problem is that, if your form truly is bound to the Country entity, then when the form is initializing, we want to prefill the cities property with the ONE City that is related to that Country. But each Country relates to Many cities... not just one. So, something here isn't making sense.

This leaves me with two main questions:

A) In the form, is the user truly meant to select just one city? Or do you actually intend for them to select multiple?
B) And, if you DO want the form to allow selecting just one city, is it possible that the form should not be bound to the Country entity? What I mean is: maybe your form is not meant to edit/create a Country but is used in some other way? What will you do with this data after a successful form submit?

Cheers!

Reply
ZakiZak Avatar

Hi Weaverryan !

You're true. I have chosen Entity on this field because i didn't know how can i access to all countries with ChoiceType. ($country=>getName()). It works with EntityType but if there are others solutions i take it.

A) The user select one City among some cities
B) Yes the form should not be bound to the Country. My goal is to create a user profile with those informations like :
Welcome username, from city(country) and your neighborhood is (neighborhood).

This project is essentially to learn form events.
I don't know if i reply to your question

Thank you !

Reply
Cyril Avatar

Hey.
I'm trying to do something pretty difficult (for me) with Form Component. I haven't found a solution yet so I'm sending an SOS here. I hope my explaination will be clear:

I have 2 entities with ManyToOne relation: Task and Tech. Of course, each technician could have many tasks...

In TaskType, I want to choose a technician AND if needed directly update some data about them (address, phone number). So I'm using a custom TechType and I did persist cascade from Task to Tech (and even @Assert\Valid on tech property of Task). IMO, everything is okay here.

In TechType, in addition to the classic fields (address, phone...), I have a special "name" field (TextType with autocomplete) used to CHOOSE the technician, not to update the name. After selecting a technician in the dropdown list, I'm reloading the TechType part with Ajax. Then, if needed, I can update some data in other fields.

I did many things (relatively) deep inside Form Component to create this functionality: EventListeners, DataTransformers, Underlying data... Everything seems to be OK.

BUT when submitting the form, I have an unexpected behavior: the technician is not updated but a new one is created. As I have a UniqueEntity(name) for Tech, I have a validation error "This name is already used for another technician". It seems like Doctrine is tracking a specific Tech and can't replace it with another one loaded by Ajax.

Did anyone have already done such a thing? Is there a Doctrine ID for tracking (I saw #id while dumping data to compare) ? Or maybe there's another way to do this ? If someone have an idea, it will be helpful!

Thanks a lot.
Cyril, from France.

Reply

Hey Cyril S.!

Oh boy - form events. This stuff is hard for me too :p. But wow, you've already accomplished a ton! Nice work!

I may not have the correct or best solution, bit maybe I can point you in the right direction - you seem pretty capable of figuring stuff out ;).

When you ultimately submit the form (after all the ajax fanciness), you are submitting some "task" data and also some "task[tech]" data for the technician. But, i believe that you are not actually sending the technician "id" anywhere in the POST data. I could totally be wrong about that part - and I hope I am. Somehow you DO need to communicate the "tech id" in the POST data somewhere. It could even be a random hidden input field that is not even part of your form. Because... in your controller, when you create the new Task() (or maybe you let the form create this object automatically - if you do, please start creating it manually), you can THEN read that POST'ed tech id value, query for the Tech object, and call $task->setTech($tech). That, I believe, will fix your problem :). Ultimately, when the form starts processing, if the Task object does not have a Tech object on it, then the TechType will automatically create one for you, which is why you're ultimately hitting this issue.

Let me know if this helps you solve it :).

Cheers!

Reply
Cyril Avatar

Yes! It worked! You're the King, Ryan!
Thank you for your help and, once again, for your great tutorials. I'm looking forward to the next one on "Turbo".
Cyril

1 Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | posted 2 years ago

Hey. Why do we use the POST_SUBMIT event ? And not PRE_SUBMIT? I mean, would it not be better, to do the change before validation?

Reply

Hey Michael B.!

That's an excellent question :). So... the true answer is... I don't know. Or, at least, I don't remember. This stuff is SO complex (one of my least favorite parts of Symfony) and I can't remember all the effects of the events. But, it's definitely worth a try. You would need to get the submitting data in a different way (it's passed to the event), but that shouldn't be a problem. There is also a SUBMIT event as well, which is in the middle.

And, about validation, I can't remember if that was something I had checked into or not. As I'm guessing you already know, validation is performed on a listener to POST_SUBMIT. But is that called before our listener or after? I don't remember. As it's written, but have a priority of 0. Anyways, if your code *were* being called after validation, we could change the priority on our event listener to guarantee it comes first.

So... that's a bunch of wishy-washy answers :P. But I hope it helps.

Cheers!

1 Reply
Braunstetter Avatar
Braunstetter Avatar Braunstetter | weaverryan | posted 2 years ago | edited

Hey weaverryan .

Yes, the form component is and always has been one of the most complicated things about Symfony.

In the last year I did a lot with the JAM stack and dealt with Laravel (wrote 2 packages). At Laravel in particular, it was the first thing that struck me - how super easy it is to validate forms there. Totally painless.

On the other hand, one of Symfony's strengths is that everything is a piece and everything interlocks. I have also implemented very complex forms with Symfony. Timetables for schools, for example, everything is possible if you work your way into it.

Anyway. I think all the other technologies are cool, but now, having looked elsewhere, I come back to Symfony and will stay. You have to choose. I've been around too long and there are many areas where I just like Symfony a lot better.

I would also like to write more about Symfony in the future and help spread the framework. Because I have the feeling that it doesn't look good for the future if nobody does that. The Laravel community is much more active. And new developers are more likely to use Laravel at the moment, because it is simply much more present.

Reply
Branko L. Avatar
Branko L. Avatar Branko L. | posted 2 years ago

Hi,

I was coding my custom application along with your videos. I wanted to clean up my PersonFormType so I transfered EventListeners into separate "Listener" file. (like the ones from ArticleFormType in you videos)

Now I am getting this error which I cannot resolve. Can you help?
Code is exactly the same as in you videos.

Argument 1 passed to App\EventSubscriber\NumberTypeByCitizenshipSubscriber::setupNumberField() must implement interface Symfony\Component\Form\FormInterface, null given, called in C:\WAMP\www\blablabla\src\EventSubscriber\NumberTypeByCitizenshipSubscriber.php on line 43

PersonFormType.php
https://pastebin.com/W0krQjyG

NumberTypeByCitizenshipSubscriber.php
https://pastebin.com/gCcfKbXw

Thank you!

Reply

Hey SentinelBL,

It looks like that somewhere in your code where you call that "setupNumberField()" method you passes a null instead of a form object. Why? That's the correct question! Please, try to debug your code, basically you can add "dd($form)" before calling that setupNumberField() in all places and see in which cases it's null. It should give you some clue about what's going on.

I hope this helps!

Cheers!

1 Reply
Default user avatar

Hi,
it's possible to do that for 3 feld?
Example: I had Country, City and Partner
City depends on Country and Partner depends on City

Reply

Hey aosow!

I don't see why not! You will just need to add a new event listener to this new dynamic form field and repeat the process feeding into your City field. Let us know if you find any unforeseen difficulties in doing this!

Reply
Default user avatar
Default user avatar aosow | shadowc | posted 2 years ago | edited

<?php

namespace App\Form;

use App\Entity\City;
use App\Entity\Country;
use App\Entity\Partner;
use App\Entity\Recipient;
use App\Entity\Transfert;
use App\Repository\CityRepository;
Use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Intl\Countries;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;

class TransfertType extends AbstractType
{

    private $cityRepo;

    public function __construct(CityRepository $cityRepo)
    {
        $this->cityRepo = $cityRepo;
    }

    /**
     * Configuration de base d'un champ (cc: champ configuration)
     * 
     * 
     */
    private function cc($label, $placeholder, $class, $options = [])
    {
        return array_merge([
            'label' => $label,
            'attr'  => [
                'placeholder' => $placeholder,
                'class' => $class
            ]
        ], $options);
    }

    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('amount', TextType::class, $this->cc("Montant à envoyer *", "", ""))
            ->add('withdrawalAmount', TextType::class, $this->cc("Montant à recevoir *", "", ""))
            ->add('fees', TextType::class, $this->cc("Frais *", "", ""))
            ->add('total', TextType::class, $this->cc("Total à payer *", "", ""))
            ->add('paymentCurrency', ChoiceType::class, [
                'choices'  => [
                    'EUR'      => 'EUR',
                    'USD'      => 'USD',
                    'CAD'      => 'CAD',
                    'XOF'      => 'XOF'
                ],
                'label' => 'Monnaie depart *'
            ])
            ->add('recipient', EntityType::class, [
                'class' => Recipient::class,
                'choice_label' => function ($recipient) {
                    return $recipient->getFirstname() . ' ' . $recipient->getLastname();
                },
                'label' => 'Destinataire',
                'placeholder' => 'Selectionner un bénéficiare',
            ])
            ->add('withdrawalType', ChoiceType::class, [
                'choices'  => [
                    'Retrait en espèces'      => 'Retrait en espèces',
                    'Virement bancaire'      => 'Virement bancaire',
                    'MTN Mobile Money'      => 'MTN Mobile Money',
                    'Orange Money'      => 'Orange Money'
                ],
                'label' => 'Type de retrait*',
            ])
            ->add('country', EntityType::class, [
                'class' => Country::class,
                'query_builder' => function (EntityRepository $er) {
                    return $er->createQueryBuilder('c')
                        ->where('c.isAvailable = :val')
                        ->setParameter('val', true)
                        ->orderBy('c.id', 'ASC');
                },
                'choice_label' => function ($country) {
                    return Countries::getName($country->getName(), 'fr');
                },
                'label' => 'Pays d\'établissement',
                'placeholder' => 'Selectionner un pays',
            ])
        ;

        $this->handleCity($builder);
        $this->handlePartner($builder);
    }

    private function handleCity(FormBuilderInterface $builder)
    {
        $formAddCity = function (FormInterface $form, Country $country = null) {

            $cities = null === $country ? [] : $country->getCities();

            $form->add('city', EntityType::class, [
                'class' => 'App\Entity\City',
                'placeholder' => 'Selectionner la ville',
                'choices' => $cities,
                'choice_label' => 'name',
            ]);
        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formAddCity) {

                $transfert = $event->getData();

                $formAddCity($event->getForm(), $transfert->getCountry());
            }
        );

        $builder->get('country')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formAddCity) {

                $country = $event->getForm()->getData();

                $formAddCity($event->getForm()->getParent(), $country);
            }
        );
    }

    private function handlePartner(FormBuilderInterface $builder)
    {
        $formAddPartner = function (FormInterface $form, City $city = null) {

            $partners = null === $city ? [] : $city->getPartners();

            $form->add('partner', EntityType::class, [
                'class' => 'App\Entity\Partner',
                'placeholder' => 'Selectionner un point retrait',
                'choices' => $partners,
                'choice_label' => 'comercialMane',
            ]);
        };

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            function (FormEvent $event) use ($formAddPartner) {

                $transfert = $event->getData();

                $formAddPartner($event->getForm(), $transfert->getCity());
            }
        );

        $builder->get('city')->addEventListener(
            FormEvents::POST_SUBMIT,
            function (FormEvent $event) use ($formAddPartner) {

                $city = $event->getForm()->getData();

                $formAddPartner($event->getForm()->getParent(), $city);
            }
        );
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => Transfert::class,
        ]);
    }
}
Reply
Default user avatar

it's doesn't work

Reply
Default user avatar

Hi this is what i tried to do but it doesn't work
cityRepo = $cityRepo;
}

/**
* Configuration de base d'un champ (cc: champ configuration)
*
*
*/
private function cc($label, $placeholder, $class, $options = [])
{
return array_merge([
'label' => $label,
'attr' => [
'placeholder' => $placeholder,
'class' => $class

]
], $options);
}

public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('amount', TextType::class, $this->cc("Montant à envoyer *", "", ""))
->add('withdrawalAmount', TextType::class, $this->cc("Montant à recevoir *", "", ""))
->add('fees', TextType::class, $this->cc("Frais *", "", ""))
->add('total', TextType::class, $this->cc("Total à payer *", "", ""))
->add('paymentCurrency', ChoiceType::class, [
'choices' => [
'EUR' => 'EUR',
'USD' => 'USD',
'CAD' => 'CAD',
'XOF' => 'XOF'
],
'label' => 'Monnaie depart *'
])
->add('recipient', EntityType::class, [
'class' => Recipient::class,
'choice_label' => function ($recipient) {
return $recipient->getFirstname() . ' ' . $recipient->getLastname();
},
'label' => 'Destinataire',
'placeholder' => 'Selectionner un bénéficiare',
])
->add('withdrawalType', ChoiceType::class, [
'choices' => [
'Retrait en espèces' => 'Retrait en espèces',
'Virement bancaire' => 'Virement bancaire',
'MTN Mobile Money' => 'MTN Mobile Money',
'Orange Money' => 'Orange Money'
],
'label' => 'Type de retrait*',
])
->add('country', EntityType::class, [
'class' => Country::class,
'query_builder' => function (EntityRepository $er) {
return $er->createQueryBuilder('c')
->where('c.isAvailable = :val')
->setParameter('val', true)
->orderBy('c.id', 'ASC');
},
'choice_label' => function ($country) {
return Countries::getName($country->getName(), 'fr');
},
'label' => 'Pays d\'établissement',
'placeholder' => 'Selectionner un pays',
])
;

$this->handleCity($builder);
$this->handlePartner($builder);

}

private function handleCity(FormBuilderInterface $builder)
{

$formAddCity = function (FormInterface $form, Country $country = null) {

$cities = null === $country ? [] : $country->getCities();

$form->add('city', EntityType::class, [
'class' => 'App\Entity\City',
'placeholder' => 'Selectionner la ville',
'choices' => $cities,
'choice_label' => 'name',
]);

};

$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formAddCity) {

$transfert = $event->getData();

$formAddCity($event->getForm(), $transfert->getCountry());
}
);

$builder->get('country')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) use ($formAddCity) {

$country = $event->getForm()->getData();

$formAddCity($event->getForm()->getParent(), $country);
}
);

}

private function handlePartner(FormBuilderInterface $builder)
{

$formAddPartner = function (FormInterface $form, City $city = null) {

$partners = null === $city ? [] : $city->getPartners();

$form->add('partner', EntityType::class, [
'class' => 'App\Entity\Partner',
'placeholder' => 'Selectionner un point retrait',
'choices' => $partners,
'choice_label' => 'comercialMane',
]);
};

$builder->addEventListener(
FormEvents::PRE_SET_DATA,
function (FormEvent $event) use ($formAddPartner) {

$transfert = $event->getData();

$formAddPartner($event->getForm(), $transfert->getCity());
}
);

$builder->get('city')->addEventListener(
FormEvents::POST_SUBMIT,
function (FormEvent $event) use ($formAddPartner) {

$city = $event->getForm()->getData();

$formAddPartner($event->getForm()->getParent(), $city);
}
);
}

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

Reply

Hey @aosow!

Sorry for the slow reply! This got escalated to me because... this stuff is *super* complex (one of my least favorite things to do inside of Symfony, tbh) and it's been a crazy week here :).

Anyways, are you still having the issue? If so, with the above code, what is the behavior you're seeing? Are you getting an error? Is one of the fields not being added? Is it not being added with the correct options? Does it seem to work, but you get a validation error when you submit?

Let me know!

Cheers!

Reply
Christopher Avatar
Christopher Avatar Christopher | posted 2 years ago | edited

Is it possible to remove children of a collection inside an event listener? I ran a little experiment and it doesn't seem possible.

Say I create a form that has one field 'deck' which is a collection of 'cards'. For testing, I manually add two cards when I create the form. Then I added this PRE_SUBMIT event listener to my form:
<br />$form = $event->getForm();<br />$form->get('deck')->remove(0);<br />dump($form);<br />
All is good according to the debug output. I have a form with a 'deck' collection that contains exactly one 'card' (which had 2 prior to this event). However, head on over to the controller and all is not well. Here a snippet of the controller:
<br />$form->handleRequest($form);<br />dump($form); <br />
That last dump shows two cards in the deck! What the...? Seems like shenanigans from handleRequest! So is it not possible to remove children from the form or am I doing something wrong?

Reply

Hey Christopher

You have a tiny error in the way you're trying to remove a form field. Just do this instead $form->remove('deck);
if that doesn't work, then double-check that hook into the event correctly (dump something inside your event listener function)

Cheers!

Reply
Christopher Avatar

I got this working with two important changes:
1. I also had to remove the data associated with the card I was removing from the form.
2. I had to take logic for removing the form fields and data out of the event listener and instead put it in an event subscriber. I then added the event subscriber to the 'deck' field of the form. Now I can successfully remove cards and that removal survives handlRequest.

Can you shed some light on why this doesn't work in the event listener but it does work in the event subscriber? Is there a video on this topic that might help?

Reply

Hey @CMarcus!

I wish I could explain that, but I can’t :). Event listeners and subscribers are *identical* in how they work: they are *nothing* more than different ways to register which event you want to listen to. But ultimately, you are called in the same way.

So... my guess might be (because this is easy to do with forms) that maybe you attached the event listener and subscriber to slightly different parts of the form / fields. Or, in theory (though this seems unlikely), the listener vs subscriber caused your code to be called at a slightly different time due to the priority that it was registered. But I’m *really* reaching for this :).

Anyways, this stuff is hard. I still need to sit and think hard about how to solve these situations. Subtle changes (as you’ve seen) do make a difference. So, while I can’t fully explain it, nice job getting it working.

Cheers!

Reply
Christopher Avatar

Thanks for the response, Diego. I was not clear in my intent here. I actually don't want to remove 'deck'. Deck is a collection of two cards and I want to remove one of the cards.

To give further details, 'deck' is a collection of CardType forms. The CardType form actually doesn't contain any fields until two fields get added in a PRE_SET_DATA event listener of CardType. In that event listener, a selection (checkbox) field is added along with an order field. In the DeckType form, the 'deck' collection of CardType forms is added as well as PRE_SUBMIT event listener where it iterates over each item in the collection and if the selection box is not checked, it attempts to remove that card. This removal of that card appears to work - if I dump the form as the last step of the PRE_SUBMIT event listener, I see that the card has been removed. But when I dump the form after returning from handleRequest in the controller, the card has been added back!

Also, DeckType does not have a data class. I haven't yet figured out if that's at play here or not.

One last point that may clarify things, I'm not attempting to remove cards from the collection that have already been persisted.

Reply
igor_wnek Avatar
igor_wnek Avatar igor_wnek | posted 3 years ago | edited

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?

Reply

Hey igor_wnek!

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!

Reply
Krzysztof-K Avatar
Krzysztof-K Avatar Krzysztof-K | posted 3 years ago | edited

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?

Reply

Hey Krzysztof K.!

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!

Reply
Krzysztof-K Avatar

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.

Reply

Hey Krzysztof K.!

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!

Reply
Lijana Z. Avatar
Lijana Z. Avatar Lijana Z. | posted 3 years ago

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 ?:)

Reply

Hey Lijana Z.

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!

1 Reply
Lijana Z. Avatar

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

Reply

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!

Reply
Lijana Z. Avatar

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.

Reply

> 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

Reply
Nicolas S. Avatar
Nicolas S. Avatar Nicolas S. | posted 4 years ago | edited

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 ?

Reply

Hey Nicoschwartz,

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

Cheers!

Reply
Nicolas S. Avatar
Nicolas S. Avatar Nicolas S. | Victor | posted 4 years ago | edited

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 [...]{

Reply
Nicolas S. Avatar

problem solved :)

Reply

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!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.8.0
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.2.1
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.1.6
        "symfony/console": "^4.0", // v4.1.6
        "symfony/flex": "^1.0", // v1.17.6
        "symfony/form": "^4.0", // v4.1.6
        "symfony/framework-bundle": "^4.0", // v4.1.6
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/security-bundle": "^4.0", // v4.1.6
        "symfony/serializer-pack": "^1.0", // v1.0.1
        "symfony/twig-bundle": "^4.0", // v4.1.6
        "symfony/validator": "^4.0", // v4.1.6
        "symfony/web-server-bundle": "^4.0", // v4.1.6
        "symfony/yaml": "^4.0", // v4.1.6
        "twig/extensions": "^1.5" // v1.5.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.7
        "fzaninotto/faker": "^1.7", // v1.8.0
        "symfony/debug-bundle": "^3.3|^4.0", // v4.1.6
        "symfony/dotenv": "^4.0", // v4.1.6
        "symfony/maker-bundle": "^1.0", // v1.8.0
        "symfony/monolog-bundle": "^3.0", // v3.3.0
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.1.6
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.1.6
    }
}
userVoice