Dynamic form modification in Symfony2

Warning: This blogpost has been posted over two years ago. That is a long time in development-world! The story here may not be relevant, complete or secure. Code might not be complete or obsoleted, and even my current vision might have (completely) changed on the subject. So please do read further, but use it with caution.
Posted on 19 Mar 2014
Tagged with: [ dynamic ]  [ events ]  [ form ]  [ PHP ]  [ symfony2

Sometimes (or actually, a lot of the time), handling forms will go beyond the basics. And even though Symfony2 gives you out-of-the-box a really clean way of creating forms, it sometimes just isn’t enough.

Fortunately, you are not alone in writing forms, and many posts exists with information on how to handle complex forms. In this post, I will try and demonstrate how to create a dynamic form where you can select a city based on the chosen province. 

The idea

Sometimes you need to let a user select a from a range of cities. Even in The Netherlands - a relative small country - there are still too many cities to place them all inside a single dropbox. There are different ways of handling this: auto completion, free input fields etc, but for the sake of this post, we’ve opted for having 2 different select boxes: one for selecting your province (comparable to county / state), and the next select box with all the cities inside the selected province.

As said, there are already some great posts on the subject like http://aulatic.16mb.com/wordpress/2011/08/symfony2-dynamic-forms-an-event-driven-approach/, but they all didn’t quite seem to fit.

The entities

We deal with 3 different entities. One for the province, one for the city and one for the account, where a user adds its account information. Note that the account entity does NOT have any link to a province. The province entity is just a helper for selecting the actual city. Since a city is linked to a province, you still can display the province to the user if you like.

You can find these entities at github: https://gist.github.com/jaytaph/9640066#file-account-phphttps://gist.github.com/jaytaph/9640066#file-city-php and https://gist.github.com/jaytaph/9640066#file-province-php. They are really straightforward so I won’t add them here.

The form

Obviously, the most important part of this post is the actual form type.

<?php

namespace NoxLogic\DemoBundle\Form\Type;

use Doctrine\ORM\EntityManager;
use NoxLogic\DemoBundle\Entity\Province;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class AccountType extends AbstractType {

    protected $em;

    function __construct(EntityManager $em)
    {
        $this->em = $em;
    }


    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // Name of the user
        $builder->add('name', 'text');

        /* Add additional fields... */

        $builder->add('save', 'submit');

        // Add listeners
        $builder->addEventListener(FormEvents::PRE_SET_DATA, array($this, 'onPreSetData'));
        $builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, 'onPreSubmit'));
    }


    protected function addElements(FormInterface $form, Province $province = null) {
        // Remove the submit button, we will place this at the end of the form later
        $submit = $form->get('save');
        $form->remove('save');


        // Add the province element
        $form->add('province', 'entity', array(
            'data' => $province,
            'empty_value' => '-- Choose --',
            'class' => 'NoxLogicDemoBundle:Province',
            'mapped' => false)
        );

        // Cities are empty, unless we actually supplied a province
        $cities = array();
        if ($province) {
            // Fetch the cities from specified province
            $repo = $this->em->getRepository('NoxLogicDemoBundle:City');
            $cities = $repo->findByProvince($province, array('name' => 'asc'));
        }

        // Add the city element
        $form->add('city', 'entity', array(
            'empty_value' => '-- Select a province first --',
            'class' => 'NoxLogicDemoBundle:City',
            'choices' => $cities,
        ));

        // Add submit button again, this time, it's back at the end of the form
        $form->add($submit);
    }


    function onPreSubmit(FormEvent $event) {
        $form = $event->getForm();
        $data = $event->getData();

        // Note that the data is not yet hydrated into the entity.
        $province = $this->em->getRepository('NoxLogicDemoBundle:Province')->find($data['province']);
        $this->addElements($form, $province);
    }


    function onPreSetData(FormEvent $event) {
        $account = $event->getData();
        $form = $event->getForm();

        // We might have an empty account (when we insert a new account, for instance)
        $province = $account->getCity() ? $account->getCity()->getProvince() : null;
        $this->addElements($form, $province);
    }


    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
          $resolver->setDefaults(array(
              'data_class' => 'NoxLogic\DemoBundle\Entity\Account'
          ));
    }


    public function getName()
    {
        return "account_type";
    }

}

First up, this form does some magic with data, and we need the entity manager for this to make it happen. We add this to the __constructor(), so we can either load this form as a service (recommended), or you can add the entity manager directly when instantiating your forms (not recommended).

The buildform() method is pretty simple: it will just add all the form elements, except the province and city. They will be added dynamically. You notice that I’m adding two event listeners to the form. They will be called on the form INSTANCE and we can actually do things with the form, based on the form data, as this data is available on these events.

onPreSetData()

This event method is called before populating the form with default values. This is the time we can actually add or modify fields. We use this event because this is the time we can actually create a list of cities, based on the province, which is based on the city inside the account entity. We cannot do this inside the buildForm(), because in that method we are building a generic form (the form CLASS, if you will).

The $event->getData() will actually hold the initial entity you’ve added to the form (and NOT the submitted data). In our case this could be a pre-filled account entity when we are modifying some account, or when creating a new account the account-entity will have all empty values. This is why we have a check on getCity(), because if it’s empty, we obviously cannot fetch its province through ->getProvince().

We call the $this->addElements(), which will do the actual adding for the province and city form elements.

addElements()

This method gets a form and optionally a province entity. First it will actually save and remove the “submit” button from the form. This is mostly because when we add new elements, they will be placed at the end of the form. Depending on how you actually render your form, this may or may not be a problem. Since i’m using the simple {{ form(form) }} method in twig, I want the submit button to be the last element.

Next up, we just add the province select box. Since i’m using the entity form type, and specified the ‘class’, it will automatically fetch all my provinces available, while having selected the given province as I specified it through the ‘data’ field. Notice that i also added ‘mapped’ => false, as the province element does not correspond to any field in the account entity, so it won’t try to store our province inside the account entity.

If we supplied a province entity, we can actually populate the list of cities from that province. If not, we just display an empty list of cities, and the user must select the correct province first. The city form element looks quite the same as the province select box, except we manually add a list of cities.

At the end of the method, we just add the submit element again.

onPreSubmit()

Until now, we pretty much followed the documentation from most blog posts. Here is where things get a bit different. The issue is that we are actually using a “virtual” element: the province entity. It’s not saved inside our account entity, and we add this element in the preSetData event handler. The issue with it, is that though we can add additional elements inside our event listener, we cannot add eventListeners to them anymore (we can only do this through buildForm()). A solution would be to add the ‘province’ element inside the buildForm, but then we couldn’t decently set the actual province, which is based on the city of the account-entity.

We solve this issue by using a PRE_SUBMIT event. Here, we can just do a $event->getData() to fetch our data and grab the $data['province'] which will hold the actual selected province ID.

Even though we don’t really care about the province ID, we need this information, in order to create a list of cities, based on the selected province. If not, the form validation will fail, as the preSetData() will have created either an empty city list (create new account) or a list with cities in the accounts province (updating an account). If the user modified its account to a city in another province, the form validation will fail, as the submitted ID is not an id in the choices list of the city element. It will however, not fail when you change to a city in the same province.

But to make sure that it works in all situations, we just change the cities list to the cities of the submitted province. (theoretically, in this case we could just add ALL cities, and this will work too: remember, we don’t store or care about the province, just the city).

So, we just grab the province ID from the submitted data, find the actual province and call the addElements() method to make things more generic.

There are two things that you have to take care of:

  1. we use the PRE_SUBMIT event, instead of the POST_SUBMIT. This means that our submitted data is not yet hydrated into an entity, but in our case, we don’t use the event for changing data: it’s about changing the elements and its contents so the validation of the entity later on will succeed.
  2. we add the PRE_SUBMIT listener to the main form, not the province-child.

The controller and view

Next we need to tie everything together inside the controller. Again, it’s an amazingly simple controller, but there is a bit of frontend-magic that is needed: when a user selects another province, the form must update the cities list with new elements. This is done through a simple ajax call that will reload a list of cities in the city-select box, based on the selected value of the province box.