How we simplified form customisations with Symfony Forms and JSON Schema

profile picture

Kevin Engineer

31 May 2018  ·  6 min read


When working on a customisable, white-label app like Spencer, challenges are bound to arise. In this post, we explain how we created a more efficient workflow to accommodate requests for changes to forms.

Usually, when working with Symfony and a POST/PUT form in a REST API, we would build a native view for a specific form, on all of our supported platforms (today, that’s iOS and Android, but the same would go for web or any other platform). However, this way of working gives us less flexibility towards clients, because for every field that needs to be added or removed for a specific client, development is needed.

Let’s explain this with an example.

Say that we have an update user profile form with the following fields:

  • firstname
  • lastname

In Symfony this would look like:

<?php

namespace Spencer\UserBundle\Form\Type\Put;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\HttpFoundation\Request;

/**
 * Form used to update a user his profile
 * @package Spencer\UserBundle\Form\Type\Post
 */
class UserType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array                $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->setMethod(Request::METHOD_PUT);

        $builder->add('firstname', TextType::class);
        $builder->add('lastname', TextType::class);
    }

}

But what if client X want users to enter an Instagram account? Adding that field would involve the following custom implementation:

  • both iOS and Android have to implement a custom implementation of the form to add Instagram for only Client X
  • both platforms need to be thoroughly tested to make sure that nothing broke for the other clients
  • both platforms need to make a new build
  • these new builds need to be distributed to the client
  • the REST API needs to implement a custom implementation for this client
  • the REST API needs to be thoroughly tested
  • a new deploy must happen together with the distribution of the builds

You can see why this would take 2-3 days to get everything done, tested and distributed.

In short, this involves many people and represents a big cost for such a small change. So we started brainstorming an easier solution that, 1) involves fewer people involved and 2) is less time-consuming.

Solution

Instead of looking at iOS and Android as two different, individual platforms, we tried looking at them as one “mobile” platform: is there a solution that would only require a change to 1 platform, or even better… to none at all?

This is where JSON Schema and the Mozilla React JSON Schema Form library come into play.

Instead of creating a native form for each platform, we created an extra API call that can fetch a JSON Schema form to display a user update profile form. In both of our supported platforms, we integrated the Mozilla React JSON Schema Form library to render this JSON Schema.

By this time, we’d already removed any custom implementation on the mobile applications. Now they just fetch the JSON Schema form, render it with the Mozilla library and POST/PUT the data back. They don’t even know what’s going to be on the form, and they don’t have to care!

Now that we’d eliminated the work on the mobile platforms, we still had to figure out how we were going to handle a more generic solution on the server side.

Serverside form to JSON schema

As you can read in our previous post on the subject, we wanted a solution for Spencer that can be customized for every client, with less work for a bigger time-to-market value. This is where Symfony and their Form events really prove their worth. During the execution of Symfony, a lot of events get triggered inside the application. For these events you can write listeners/subscribers that listen/subscribe to a specific event. For more info, you read this and this post, or look at Symfony’s documentation.

With the subscribers, we can dynamically modify our form, just based on configuration files. We’ve updated the form from above to just implement subscribers and remove all the text fields:

<?php

namespace Spencer\UserBundle\Form\Type\Put;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\HttpFoundation\Request;

/**
 * Form used to update a user his profile
 * @package Spencer\UserBundle\Form\Type\Post
 */
class UserType extends AbstractType
{

    /**
     * @var array
     */
    private $subscribers;

    /**
     * @param array $subscribers
     */
    public function __construct($subscribers = array())
    {
        $this->subscribers = $subscribers;
    }

    /**
     * @param FormBuilderInterface $builder
     * @param array                $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->setMethod(Request::METHOD_PUT);

        // add all the injected subscribers to the form event subscriber
        foreach ($this->subscribers as $subscriber) {
            $builder->addEventSubscriber($subscriber);
        }
    }
}

Now all we have to do is create subscribers for each field. I’ll just use the new Instagram field as an example.

<?php

namespace Spencer\UserBundle\Subscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;

/**
 * @package Spencer\UserBundle\Subscriber
 */
class InstagramPutUserTypeSubscriber implements EventSubscriberInterface
{

    /**
     * @return array
     */
    public static function getSubscribedEvents()
    {
        return [
            FormEvents::PRE_SET_DATA => 'preSetData',
            FormEvents::POST_SUBMIT  => 'postSubmit',
        ];
    }

    /**
     * @param FormEvent $event
     */
    public function preSetData(FormEvent $event)
    {
        $form = $event->getForm();

        // add instagram field to the form
        $form
            ->add(
                'instagram',
                TextType::class,
                [
                    'label'    => 'Instagram',
                    'required' => true,
                ]
            );
    }

    /**
     * @param FormEvent $event
     */
    public function postSubmit(FormEvent $event)
    {
        /**
         * Do submit logic here
         */
    }
}

The next problem we encountered is that the UserType is inside one of our bundles, meaning it would be still be a tedious job to inject the subscribers into the form.

We explained the solution we’ve implemented for this in full in the previous post. In short, we’ve created a form subscriber chain into our core bundle that will hold all the Spencer form subscribers that are tagged with spencer_core.form_subscriber. This way, every Spencer client will benefit from this solution and we will have less customization work in the future.

Now thanks to Symfony’s expression language, it is really easy to only add the correct subscribers to the UserType form by declaring the form as a service:

services:
    spencer_user.form.put_user_type:
        class: Spencer\UserBundle\Form\Type\Put\UserType
        arguments:
            - "@=service('spencer_core.form_subscriber_chain').getSubscribersByForm('spencer_user.form.put_user_type')"
        tags:
            - { name: form.type }

So in the code above we declare the UserType as a service, and in the arguments we fetch all the subscribers that are registered for this form. All we have to do now is tag the Instagram subscriber for the spencer_user.form.put_user_type like this:

services:
    spencer_user.instagram_put_user_type_subscriber:
        class: 'Spencer\UserBundle\Subscriber\InstagramPutUserTypeSubscriber'
        tags:
            - { name: spencer_core.form_subscriber, form: spencer_user.form.put_user_type }

Because we tag the subscriber with spencer_core.form_subscriber, the subscriber will be picked up in the chain, and because of the spencer_user.form.put_user_type, it will be injected into the UserType.

This workflow makes it easy to configure the UserType for every client. We can now even add custom implementations for a client without touching the Spencer bundles. So if a client wants, let’s say, a date of birth added to the user profile, we can now just create a subscriber, tag it to be added to the UserType, and voila! Done, and we didn’t touch any code inside Spencer.

Now to make a JSON schema of this Symfony form type, we use the Liform library. This allows us to output a JSON schema form, which the mobile app can then render with a library that can handle JSON schema forms, like this one from Mozilla.

Conclusion

Remember all those steps at the beginning of this post? Now let’s see which actions we need to complete today when a client asks for a custom implementation:

  • Create a subscriber for the form on server side
  • Test the JSON schema
  • Thoroughly test the REST API
  • Complete a new deploy WITHOUT distribution of new builds

In other words: the custom implementation can now be done by one person, in about two hours. Less time to market and easier maintenance work – what’s not to like!