Part 2 - Embedded Forms, Validation and Bootstrap Styling


In this video we are going to continue on with adding in an 'Other' option to our select box, and specifically we will:

  • Create the TimetableType Symfony form
  • Add in custom validation to the Timetable entity
  • Fix up the styling issues currently present in embedded forms and the Bootstrap horizontal layout

There's quite a lot to cover here as this is really where most of the heavy lifting will take place, so let's get started.

Creating the TimetableType

By the end of the previous video we had created a Timetable entity that has a one-to-one relationship with our DataFeed entity.

We also managed to saved off some data by brute-forcing our way through the form submission process, cheating a little bit to add in a new'ed up Timetable after all the validation and submission logic had already occured. The upshot of this is that we know that - if we can make the form behave - things should 'just work (tm)'.

For the moment let's completely forget about the object relationships and any of that carry on. After all, the relationship we setup was unidirectional, and our Timetable is completely unaware of who / what it is related too, anyway.

So without any complications, we are already more than capable of creating ourselves a simple Symfony form for any entity that doesn't have relations:

<?php

// /src/AppBundle/Form/Type/TimetableType.php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TimetableType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('presetChoice', ChoiceType::class, [
                'choices' => [
                    '15' => 15,
                    '30' => 30,
                    '45' => 45,
                    '60' => 60,
                    'Other' => null,
                ]
            ])
            ->add('manualEntry', IntegerType::class, [
                'required' => false,
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Timetable',
        ]);
    }
}

There are a few interesting points about this form:

The first is that it doesn't have a 'save' button. This is because this form will be embedded in another form (the DataFeedType in our case), and the save button will live on that form instead.

The second is that one of our presetChoice choices is null. Without updating our Timetable entity then this may error when trying to save - as the presetChoice annotation currently is not configured to allow null's.

Lastly we are setting the manualEntry field to be required => false, because this is going to be a kind of an either / or situation, but not exactly. If manualEntry is in use then presetChoice is going to have been set to 'Other' / null, so from the front end perspective, it will have had something selected. The required field is going to turn off the HTML5 required element, that's all. We have already seen how to work with that earlier in this series.

Using Arrays To Understand Embedding Symfony Forms

When I first got started with Symfony, the wording around 'embedding a form' scared me. It sounded complicated, and confusing. Forms inside forms inside forms? Good Lord.

Thankfully, as with most anything, the more you use this concept and with a little foundational knowledge, it becomes a lot less scary / confusing.

Disregard objects for the moment, and let's think about arrays.

We know of simple arrays that contain one level of values:

$a = [1,2,3];

And we know about two dimensional arrays:

$a = [
    ['Cat','mittens',3],
    ['Dog','bingo',7],
    ['Tortoise','tula',89],
];

And of course, multidimensional arrays:

$a = [
    ['Cat'=>['mittens',3]],
    ['Dog'=>['bingo',7]],
    ['Tortoise'=>['tula',89]],
];

Well, if you understand this, then you are most of the way there with understanding the way the form handles your data.

In our example we will have a DataFeedType which embeds a TimetableType.

We could strip out the concept of entities and work directly with arrays, and the resulting value of a form submission would be a two dimensional array. Let's quickly take a look:

<?php

// /src/AppBundle/Controller/FormExampleController.php

namespace AppBundle\Controller;

use AppBundle\Form\Type\DataFeedType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class FormExampleController extends Controller
{
    /**
     * @Route("/", name="form_add_example")
     */
    public function formAddExampleAction(Request $request)
    {
        $form = $this->createForm(DataFeedType::class, []);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {

            $dataFeed = $form->getData();

            dump($dataFeed);

            $this->addFlash('success', 'We saved a data feed');
        }

        return $this->render(':form-example:index.html.twig', [
            'myForm' => $form->createView()
        ]);
    }
}

And comment out the data_class key / value pair inside the TimetableType and DataFeedType:

// /src/AppBundle/Form/Type/DataFeedType.php

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            // 'data_class' => 'AppBundle\Entity\DataFeed',
        ]);
    }

// /src/AppBundle/Form/Type/TimetableType.php

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            // 'data_class' => 'AppBundle\Entity\Timetable',
        ]);
    }

Now, when you submit the form you should see the following dumped out:

array:4 [
    "name" => "Your name goes here",
    "url" => "https://codereviewvideos.com",
    "enabled" => true,
    "timetable" => array:2 [
        "presetChoice" => null,
        "manualEntry" => 11
    ]
]

We don't get an id set, as that's a Doctrine-managed thing. But the rest? It's all just values.

Hopefully this makes understanding what happens when working with objects that much simpler. Because ultimately, it's very, very similar.

Embedding The TimetableType Inside DataFeedType

The way I think about the form types that we use in Symfony is that they lay one layer above our objects. Hear me out on this, as this is maybe just how my mind works.

We have our objects:

DataFeed which contains a Timetable

If you are unsure on this, please take a look at the entity definitions in the previous video.

Our form(s) map over the top of these objects.

So if our DataFeed has a name, enabled, and url property, then our form has the corresponding fields of the exact same name. You can mess around with this by changing the property_path, but I wouldn't unless you really have too.

And when we added the relationship, our DataFeed gained a property called timetable.

Well, don't overthink this - our form therefore should also have a field called timetable.

But what should the form field type be?

Well, the TimetableType of course :)

Let's see this in action:

<?php

// /src/AppBundle/Form/Type/DataFeedType.php

namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class DataFeedType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name')
            ->add('url', UrlType::class)
            ->add('enabled', ChoiceType::class, [
                'choices' => [
                    'Yes'  => true,
                    'No'   => false,
                ],
                'expanded' => true,
            ])
            ->add('timetable', TimetableType::class)
            ->add('submit', SubmitType::class)
        ;
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\ProductFeed',
        ));
    }
}

It is as simple as that to create our nest, and now the form tells the same story as our objects. We expect that the timetable field contains (or embeds) a form that handles Timetable entities.

It is strange at first. I really found this whole concept very confusing, so if you do too, don't stress out about it. It will click eventually.

The reason this works like this - and it is very elegant the more you think about it, and it's the same concept / design pattern that JavaScript libraries like React make use of - is because of the Composite Design Pattern. You can read a much more in-depth article on this on Bernhard Schussek's blog. In case you didn't know, Bernhard is the author of the Symfony form component.

Making Our Form Look Nice

The next problem is that there is a known issue with the Bootstrap 3 theming (at the time of writing) which causes embedded forms to gain an extra CSS class that looks bad:

embedded form looks wrong in Symfony Bootstrap 3 horizontal layout

The solution to this is simply to render the form fields manually - which isn't anything like as bad as it sounds, but is still a bit of a pain as it's one more thing to keep on top of when adding new fields to your form.

<!-- /app/Resources/views/form-example/index.html.twig -->

{% extends '::base.html.twig' %}

{% block body %}

    <h2>Symfony 3 Form Example</h2>

    <hr />

    {#{{ form(myForm) }}#}

    {{ form_start(myForm) }}

    {{ form_errors(myForm) }}

    {{ form_row(myForm.name) }}
    {{ form_row(myForm.url) }}
    {{ form_row(myForm.enabled) }}

    <div class="{% if not myForm.timetable.vars.valid %}has-error{% endif %}">
        {{ form_row(myForm.timetable.presetChoice) }}
        {{ form_row(myForm.timetable.manualEntry) }}

        <div class="col-sm-offset-2">
            {{ form_errors(myForm.timetable) }}
        </div>
    </div>

    {{ form_row(myForm.save, { 'attr': { 'class': 'btn btn-success' } }) }}

    {{ form_end(myForm) }}

{% endblock %}

There's nothing particularly mind-blowing going on here. We've seen the use of vars.valid before in this series, and adding CSS classes inside the Twig templates (for the 'save' button in this case).

What is new here is the embedded form - myForm.timetable.

This same dot notation syntax follows down no matter how deeply you nest your forms. My advice, by the way, would be not to nest too deeply. If you are going beyond three layers of embedding / nesting then likely things could be refactored to a simpler implementation.

I've skipped ahead here somewhat as the validation logic isn't yet in place. Let's fix that.

Adding Custom Timetable Validation Logic

The requirement for our system is to have a form where a user can select one of the preset options, OR they can select the 'other' option and only then add in what we are calling a manual entry.

Right now, without any validation logic, this is not the case. A user can add in any combination they feel like, and generate all kinds of errors along the way.

Firstly, it would make sense to enable either field to be nullable. Currently, without the correct Doctrine annotation this would throw an error on save, whereby Doctrine tries to save a null to a field that is not nullable. Oops.

Secondly, there is no Symfony built-in validation constraint to do what we want directly. But thankfully there is a validation constraint that allows us to define our own validation logic - the Callback Validation Constraint.

<?php

// /src/AppBundle/Entity/Timetable.php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

/**
 * Timetable
 * @ORM\Table(name="timetable")
 * @ORM\Entity()
 */
class Timetable
{
    /**
     * @Assert\Callback()
     */
    public function validate(ExecutionContextInterface $context)
    {
        if ($this->getPresetChoice() === null && $this->getManualEntry() === null) {
            $context->buildViolation('Either a preset, or a manual entry must be supplied')->addViolation();
        }

        if ($this->getPresetChoice() !== null && $this->getManualEntry() !== null) {
            $context->buildViolation('Cannot use both a preset and a manual entry')->addViolation();
        }
    }

    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id()
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="integer", nullable=true)
     */
    protected $presetChoice;

    /**
     * @ORM\Column(type="integer", nullable=true)
     */
    protected $manualEntry;

    // * snip *

Watch the video for a better understanding of how this validation logic works (starting around the 8:00 mark).

The gist of this is that rather than add the errors to either of the fields directly, I have added the errors to the Timetable entity as a whole, and that's why I'm only using the single twig form error helper:

{{ form_errors(myForm.timetable) }}

There are two other issues we need to address here.

Firstly, we must tell the parent entity - the DataFeed entity - that we want to validate its related Timetable entity:

// /src/AppBundle/Entity/DataFeed.php

    /**
     * @ORM\OneToOne(targetEntity="Timetable", cascade={"persist"})
     * @ORM\JoinColumn(name="timetable_id", referencedColumnName="id")
     *
     * @Assert\Valid()
     */
    protected $timetable;

Secondly, we must stop the form errors bubbling up to the parent form, to stop a situation where an error in the input of a Timetable property may end up as a generic form error, rather than next to the offending timetable section. This becomes that little bit more important if you have more than one timetable on your form at a time - as we shall do in the next video.

// /src/AppBundle/Form/Type/TimetableType.php

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Timetable',
            'error_bubbling' => false,
        ]);
    }

And with that, the vast majority of the hard work is now done. All that remains is to add in a little bit of JavaScript to show or hide the 'other' box depending on what option has been selected in our presetChoice dropdown.

To make things a little more interesting though, we will make the JavaScript capable of handing more than one instance of the TimetableType being embedded at the same time. We'll do that in the very next video.

Code For This Course

Get the code for this course.

Episodes