Observer pattern in Symfony forms

Observer pattern in Symfony forms

In this post demonstrates how to leverage form events in Symfony. We will develop an application with a single form that allows adding and modifying locations by choosing a country, then a region within the chosen country, then a city within the chosen region. Of course, the list of regions must be updated every time the country changes as well as the list of available cities must be updated every time the region changes. Observing some form events will come in handy in this setting.

There are few quirks to take into account:

  • Locaiton is an entity that contains a single property - $city. The $city properity is a reference to the City entity which in turn references the Region entity. Finally the Region entity contains a reference to the Country entity. It is evident that the only reference to the city in the location is sufficent to retrieve its region and its country.

  • LocationType is a form type to add and modity Location objects. It shoud redger three <select> inputs to fill: county, region and city. There is an evident disparity between the number of fields in the location form and in the location entity that is mapped to it. Hence the only mapped field is the city field. region and country fields should adapt themselves according to the data of the city field.

  • when a new location is being created the list of countries should contain all counties, no default country should be chosen. The list of regions and cities should be empty.

  • when an existing location is being modified the city, the country and the region must be already chosen (Paris -> Île-de-France -> France). Moreover the list of regions should be already limited to the french regions and the list of cities should be limited to the Île-de-France region.

  • AJAX is used to update the region list whenever the county changes as well as to update the city list whenever the region or the country changes.

Concepts

There are three concepts to understand before we delve into the implementation:

  • form type class represents a model for form object, it contains definitions of all fields in form.

  • mapped object of any type. This is an object that gets created or edited my means of form. It is assigned to form object in order to be read and modified by it.

  • form object is responsible for many things: containing all form fields, reading data from mapped object and passing this data to corresponding form fields, reading submitted data and writing this data back to mapped object. form object is not an instance of form type class, insted it is constructed based on form type class.

These concepts put together are familiar to any Symfony developper:

$form = $formFactory->create(FooType::class, $foo);

We crate a new $form object that contains fields defined in FooType with default values retrieved from $foo object.

Form types are designed to represent forms without reference to mapped objects. In fact most of your forms require the same field definitions regardless the actual data in mapped objects nor in submitted data. But sometimes it is vital to adapt a form based on mapped data. The solution is to execute your custom code just after mapped data gets available but just before it gets mapped to form fields. Form events to the rescue.

In worth nothing that every form undergo two basic operations:

  • populating means assigning default data to form fields.
  • submission means treating raw submitted data and mapping it to the object associated with form.

Form populating

Let’s concentrate on the form populating workflow. Here is an illustration of it:

If we apply this schema to our case what happens is that:

  • firstly, the $form object is created with the fields defined in LocationType::buildForm. No data is mapped to them.
  • then data is about to be taken from $location object and set to each mapped field in $form. All listeners of FormEvents::PRE_SET_DATA are called. They all get a reference to the $form object and to the $location object, hence they can modify the $form.
  • data is mapped to the $form, it is populated.

There are to situations to consider: creation of a new $location and modification of existing one. But these situations are the same for forms: either it is $location = new Location(); or $location = $locationRepository->find($id);. The fact that the object is freshly created or returned from database is not important because we alway have a location object that we have to assign to form.

LocationController to create and manage locations:

namespace App\Controller;

use App\Entity\Location;
use App\Form\Type\LocationType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
 * The controller to manage locations:
 * - create location @see LocationController::createAction()
 * - edit existing location @see LocationController::editAction().
 */
class LocationController
{
    public function createAction(
        Request $request,
        EntityManagerInterface $em,
        FormFactoryInterface $formFactory,
        UrlGeneratorInterface $urlGenerator
    ) {
        // Creating a new location.
        // Take note that you can assign a default city to it so the form will
        // be populated with corresponding country, region and city.
        $location = new Location();

        // Creating a location form. FormEvents::PRE_SET_DATA is dispatched here.
        $form = $formFactory->create(LocationType::class, $location);

        // Handling submitting. FormEvents::PRE_SUBMIT is dispatched here.
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->persist($location);
            $em->flush();

            return new RedirectResponse($urlGenerator->generate('location_list'));
        }

        return [
            'form' => $form->createView(),
        ];
    }

    public function editAction(
        Location $location,
        Request $request,
        EntityManagerInterface $em,
        FormFactoryInterface $formFactory,
        UrlGeneratorInterface $urlGenerator
    ) {
        $form = $formFactory->create(LocationType::class, $location);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $em->flush();

            return new RedirectResponse($urlGenerator->generate('location_list'));
        }

        return [
            'form' => $form->createView(),
        ];
    }
}

LocationType will add its fields in event listeners because they all depend on mapped data:

namespace App\Form\Type;

use App\Entity\City;
use App\Entity\Country;
use App\Entity\Location;
use App\Entity\Region;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
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;

/**
 * Form type for create a new location or edit the existing one.
 *
 * @see Location
 *
 * @author Vlad Riabchenko <contact@vria.eu>
 */
class LocationType extends AbstractType
{
    /**
     * @var EntityManagerInterface
     */
    private $em;

    /**
     * Constructor.
     *
     * @param EntityManagerInterface $em
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // Instead of adding the fields `country`, `region` and `city` directly
        // in this method we will delegate this to event listeners because the
        // options of these fields depend on underlying data.
        //
        // There are few good reasons for this :
        //
        // 1. Neither `country` nor `region` are stored in location and mapped
        // to form. It is necessary to infer `country` and `region` values for
        // the given city in location and assign them to form fields. Otherwise
        // these fields will be rendered empty - any option will be selected.
        //
        // 2. We need to limit the number of options in `region` field based on
        // a chosen country as well as to limit the number of options in `city`
        // field based on a chosen region.
        $builder->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData']);
    }

    /**
     * FormEvents::PRE_SET_DATA listener. This method adds country`, `region`
     * and `city` fields based on data stored in database.
     *
     * For example, when an existing location reference the city of "Paris" then
     * the region field must be set to "Île-de-France" and the country field
     * must be set to "France".
     *
     * At the same time the choice of cities will be limited to "Paris",
     * "Versailles", "Sèvres" an other cities of "Île-de-France" region. The
     * regions will be limited to "Île-de-France", "Hauts-de-France",
     * "Normandie" and other regions of the country of "France".
     *
     * When the location is being created, the lists of regions and cities will
     * be empty because the country will not be selected.
     *
     * @param FormEvent $event
     *
     * @author Vlad Riabchenko <vriabchenko@webnet.fr>
     */
    public function onPreSetData(FormEvent $event)
    {
        /** @var Location $location */
        $location = $event->getData();
        $city = $location instanceof Location ? $location->city : null;
        $region = $city ? $city->region : null;
        $country = $region ? $region->country : null;

        $this->addCountry($event->getForm(), $country);
        $this->addRegion($event->getForm(), $country, $region);
        $this->addCity($event->getForm(), $region);
    }

    /**
     * Add `country` field to the form. 
     * This field is not mapped directly from or to the location object. 
     *
     * @param FormInterface $form
     * @param Country|null  $country
     */
    private function addCountry(FormInterface $form, Country $country = null)
    {
        $form->add('country', EntityType::class, [
            'class' => Country::class,
            'choice_label' => 'name',
            'mapped' => false,
            'required' => true,
            'placeholder' => 'Select a country',
            'data' => $country,
        ]);
    }

    /**
     * Add `region` field to the form.
     * This field is not mapped directly from or to the location object. 
     *
     * @param FormInterface $form
     * @param Country|null  $country
     * @param Region|null   $region
     */
    private function addRegion(FormInterface $form, Country $country = null, Region $region = null)
    {
        $form
            ->add('region', EntityType::class, [
                'class' => Region::class,
                'choice_label' => 'name',
                'mapped' => false,
                'required' => true,
                'placeholder' => 'Select a region',
                'data' => $region,
                'query_builder' => function (EntityRepository $repository) use ($country) {
                    return $repository
                        ->createQueryBuilder('r')
                        ->andWhere('r.country = :country')
                        ->setParameter('country', $country)
                    ;
                },
            ])
        ;
    }

    /**
     * Add `city` field to the form.
     *
     * @param FormInterface $form
     * @param Region|null   $region
     */
    private function addCity(FormInterface $form, Region $region = null)
    {
        $form->add('city', EntityType::class, [
            'class' => City::class,
            'choice_label' => 'name',
            'placeholder' => 'Select a city',
            'query_builder' => function (EntityRepository $repository) use ($region) {
                return $repository
                    ->createQueryBuilder('c')
                    ->andWhere('c.region = :region')
                    ->setParameter('region', $region)
                ;
            },
        ]);
    }
}

With a bit of JavaScript and AJAX the needed functionality will be completed:

/**
 * Script used in :
 * - @AppBundle/location/create.html.twig
 * - @AppBundle/location/edit.html.twig
 *
 * @author Vlad Riabchenko <contact@vria.eu>
 */
$(document).ready(function() {
    var $locationCountry = $('#location_country');
    var $locationRegion = $('#location_region');
    var $locationCity = $('#location_city');

    /**
     * When the country is changed this function clears all regions and cities.
     * Then the regions list is repopulated with the options corresponding to
     * chosen country.
     */
    $locationCountry.change(function() {
        // Clear the regions and cities lists.
        $locationRegion.find('option[value!=""]').remove();
        $locationCity.find('option[value!=""]').remove();

        // Get regions corresponding to chosen country.
        $.ajax({
            url: Routing.generate('location_get_regions_for_country', {id: $locationCountry.val()}),
            method: 'GET',
            success: function (data) {
                // Repopulate regions list
                data.regions.forEach(function(region) {
                    $locationRegion.append('<option value="' + region.id + '">' + region.name + '</option>');
                });
            }
        });
    });

    /**
     * When the region is changed this function clears current cities.
     * Then the cities list gets repopulated with the options corresponding
     * to the chosen region.
     */
    $locationRegion.change(function() {
        // Basically the same code as in the previous listener.
    });
});

Form submission workflow

So far so good. Nevertheless there is annoying error that sometimes pops up on form sumbit:

This happends because you cannot sumit a value that form component does not expect to recieve. Let me illustrate. When you start editing a location that points to the city of Munich then your current region is Bavaria so the list of available cities contains only the cities in Bavaria. It is perfectly legit to chose other region, say Baden-Württemberg, and to select a new city within this new region, say Stuttgart. If you sumbit these values you will definetly get an error of the cities list: This value is not valid. This is because the form still expetcs to recieve only the bavarian cities.

The error of the region list is exactly the same. If you change the country, say France to Germany, and than you change the region, say Île-de-France to Bavaria, the form will still expect the french region and not a german one.

The solution is to adapt the form once again. This time we need to adapt it to the submitted values. The adapting logic is exactly the same as for populating. We will execute it in PRE_SUBMIT_DATA event listener:

We need extend LocationType class by adding a new listener method onPreSubmit:

namespace App\Form\Type;

class LocationType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'onPreSetData'])
            ->addEventListener(FormEvents::PRE_SUBMIT, [$this, 'onPreSubmit'])
        ;
    }

    // onPreSetData(...)

    /**
     * FormEvents::PRE_SUBMIT listener.
     *
     * This listener updates all lists based on the request data. In fact, when
     * a user submits the form then a country, a region and a city may change.
     * If the a country has changed, then the list of regions will be different
     * than it was when the form was populated.
     * If you leave the old list with old values and send a value that is beyond 
     * this list then the validation error will pop up.
     *
     * @param FormEvent $event
     *
     * @author Vlad Riabchenko <vriabchenko@webnet.fr>
     */
    public function onPreSubmit(FormEvent $event)
    {
        // The submitted data from request
        $requestData = $event->getData();

        /** @var Country $country */
        $country = !empty($requestData['country'])
            ? $this->em->getRepository(Country::class)->find($requestData['country'])
            : null;

        /** @var Region $region */
        $region = !empty($requestData['region'])
            ? $this->em->getRepository(Region::class)->find($requestData['region'])
            : null;

        $this->addCountry($event->getForm());
        $this->addRegion($event->getForm(), $country, $region);
        $this->addCity($event->getForm(), $region);
    }
    
    // addCountry(...), addRegion(...), addCity(...)
}

You have just read the essence of the matter without the details that are not important for understanding. But they are important for proper function of the application. I encourage you to take a look at the complete code, clone it and test.