[Part 1] - Twig Extensions - How to add a custom CSS class to an invalid form field


In this video we going to cover one possible solution to the following problem:

How can I add a custom CSS class to the input / other form field when validation fails for that field?

It may be that you are covered by the default way that Symfony renders form error messages in Twig templates. If so, you don't need to continue here.

If you'd like to add your own custom CSS class or classes to the form field itself, then keep reading.

What we will do is to make use of a form field's attrs to bend the way the HTML is rendered in order to meet our needs.

Is this a hack? I will leave that for you to decide.

As ever, feel free to share improvements and alternatives. Whether you approve of this, or not, hopefully you find the explored concepts to be interesting.

Possible Solution

What we have is a very simple application.

We have a controller that renders out our form.

When we submit the form, it POSTs back to this controller action.

We aren't using the output of the form submission in any meaningful way.

<?php

// src/AppBundle/Controller/DefaultController.php

namespace AppBundle\Controller;

use AppBundle\Form\Type\WidgetType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     * @param Request $request
     * @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
     */
    public function indexAction(Request $request)
    {
        $form = $this->createForm(WidgetType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            dump('VALID');
        }

        if ($form->isSubmitted() && false === $form->isValid()) {
            dump('INVALID');
        }

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

We're being as RAD as possible in the way we are rendering the form:

{# app/Resources/views/default/index.html.twig #}

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

{% block body %}
    {{ form(form) }}
{% endblock %}

base.html.twig in this case is as comes with a fresh installation of Symfony (symfony new ...).

WidgetType is next:

<?php

// src/AppBundle/Form/Type/WidgetType.php

namespace AppBundle\Form\Type;

use AppBundle\Entity\Widget;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class WidgetType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', TextType::class, [
                'attr' => [
                    // this will always show, and is a standard html attribute
                    'class'                       => 'class-added-in-form-type',
                    // this will only show if there are errors on the form
                    // but allows you to customise which class to add
                    // when there are errors
                    'data-custom-error-css-class' => 'some-css-error-class another-error-class',
                ]
            ])
            // another form field with no defaults, should not be impacted at all
            ->add('another')
            ->add('submit', SubmitType::class)
        ;
    }

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

Now things get more interesting.

Starting with configureOptions the form is set to novalidate. This is to allow us to submit the form without HTML5 validation stopping us from submitting empty fields.

From the configureOptions function we can see AppBundle\Entity\Widget is the class that will be used to contain the submitted form data.

buildForm adds our three fields.

name and another are properties of our Widget entity. Both are currently set to simple input / TextType::class fields types. What we are going to cover should work for any other field type also.

$builder
    ->add('name', TextType::class, [
        'attr' => [
            'class'                       => 'class-added-in-form-type',
            'data-custom-error-css-class' => 'some-css-error-class another-error-class',
        ]
    ])

As the third argument to add we pass in an array.

This array contains the commonly used attr key.

This key allows you to set the values of HTML attribute key/value pairs on the form field when rendered by Twig.

Any field we add here will be added, regardless of whether it's some pre-defined attribute or not.

In other words, by doing this our rendered input for this field would be:

<input
  type="text"
  id="widget_name"
  name="widget[name]"
  required="required"
  class="class-added-in-form-type"
  data-custom-error-css-class="some-css-error-class another-error-class"
>

And what we want is when the form field fails validation, we append the contents of data-custom-error-css-class to the class's value:

<input
  type="text"
  id="widget_name"
  name="widget[name]"
  required="required"
  class="class-added-in-form-type some-css-error-class another-error-class"
  data-custom-error-css-class="some-css-error-class another-error-class"
>

Having the data-custom-error-css-class attribute as a left over is a bit unfortunate.

Simply prepending the attribute with data- is enough to make it a valid HTML attribute all the same.

To begin with we will work towards a static error CSS class to be added, and then we will expand on this to make it so we can modify the error class as above.

Already an Edge Case

But there's already an edge case.

What if attr is not set. Or attr is set, but either class, or data-custom-error-css-class are not defined.

It's obvious in our heads that if attr is not set then we don't need to worry. We don't need a class attribute.

And it makes sense that that if attr is set, but either class, or data-custom-error-css-class are not defined then we can likely just return whatever is defined.

Remember, we need everything to behave as expected, we're just adding some augmentation.

Yellow Alert

We're at yellow alert. Shields are up, and we're ready to get defensive.

Let's take a peek at the entity:

<?php

// src/AppBundle/Entity/Widget.php

namespace AppBundle\Entity;

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

/**
 * @ORM\Entity()
 * @ORM\Table(name="widget")
 */
class Widget
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\column(type="string")
     * @Assert\NotBlank(message="This is a required field")
     */
    private $name;

    /**
     * @ORM\column(type="string")
     * @Assert\NotBlank(message="This is a required field")
     */
    private $another;

    // `getter` / `setter` removed for brevity
}

The important parts here being the Assert annotations.

At this point, we should be able to render our form without issue. Because we have novalidate set as an attribute of our form, we can submit the form, and Symfony renders the error messages above the respective fields.

Custom Form Theme

Now we're going to get adventurous.

We're going to create a custom form theme that allows us to pull everything together.

What we have is a presentational problem. We need to push the presentation logic as outermost as we possibly can - from out of the 'core' of our app, and into the Twig-gy / template-y world of HTMhelL.

Thinking back to the original question:

... to the input / form field itself ...

I'm working on the assumption that what I need to fix here is that every form field type may need this feature.

I don't know of a way to apply this 'fix' to all form fields in one go. Instead, we're going to need to alter five different Twig blocks:

{# /app/Resources/views/custom-form-error.html.twig #}

{% extends "form_div_layout.html.twig" %}

{% block form_widget_simple -%}
    {% if errors|length > 0 -%}
        {% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
    {% endif %}
    {{- parent() -}}
{%- endblock form_widget_simple %}

{% block textarea_widget -%}
    {% if errors|length > 0 -%}
        {% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
    {% endif %}
    {{- parent() -}}
{%- endblock textarea_widget %}

{% block choice_widget_collapsed -%}
    {% if errors|length > 0 -%}
        {% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
    {% endif %}
    {{- parent() -}}
{%- endblock choice_widget_collapsed %}

{% block checkbox_widget -%}
    {% if errors|length > 0 -%}
        {% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
    {% endif %}
    {{- parent() -}}
{%- endblock checkbox_widget %}

{% block radio_widget -%}
    {% if 'radio-inline' not in parent_label_class %}
        {% if errors|length > 0 -%}
            {% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
        {% endif %}
    {% endif %}
    {{- parent() -}}
{%- endblock radio_widget %}

I'm not going to try and take credit for this idea. I borrowed it from the foundation_5_layout.html.twig file.

In Symfony when using Twig, pretty much everything of importance regarding the way forms and their fields are rendered happens in one file:

/vendor/symfony/symfony/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig

This is a near 400 line monster at the time of recording.

We start off by extends "form_div_layout.html.twig". In doing this we make sure we are inheriting from the base form_div_layout.html.twig file, and therefore any calls to parent() will call the code from the same named block after executing our overrides.

Each widget block is roughly the same in logic.

set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim})

This is the key piece of the puzzle.

We want to override the existing value of attr.

We want to replace the value stored under the class key with whatever is currently in the attr.class key, or an empty string if not it is not set.

Notice that merge takes a funky hash (basically a key / value pair) using curly braces: {class: ...}. See the Twig docs for a further example.

The value of our hash is this attr.class value, or the '' empty string concatenated (~) with ' error'. The space is important as if attr.class is set, then we don't want error being stuck right next to it. Think some-classerror. Oops. We need that space.

Piping through trim ensures any leading or trailing spaces get removed.

Finally for each block once we've done our necessary changes, we call parent() to do whatever else needs to be done to output the expected HTML.

To make this work we need to add in an entry under form_themes to the twig section of config.yml:

# app/config/config.yml

# Twig Configuration
twig:
    debug: '%kernel.debug%'
    strict_variables: '%kernel.debug%'
    form_themes:
        - 'custom-form-error.html.twig'

At this point our logic should be working.

To make this more obvious add the following extra code to index.html.twig:

{# app/Resources/views/default/index.html.twig #}

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

{% block body %}
    {{ form(form) }}
{% endblock %}

{% block stylesheets %}
<style>
    .some-css-error-class {
        background: red;
        padding: 2em;
        border: 1px solid green;
    }
    .another-error-class {
        background: blue;
        padding: 2em;
        border: 1px solid orange;
    }
</style>
{% endblock %}

Boy that's some ugly output, but it proves this works.

In the next video we're going to use a Twig extension to tidy it up just a touch.

Code For This Video

Get the code for this video.

Episodes

# Title Duration
1 How can I create a Maintenance Page in Symfony? 05:52
2 How can I implement Sorting in a Symfony 3 JSON API? 12:37
3 [Part 1] - Twig Extensions - How to add a custom CSS class to an invalid form field 07:33
4 [Part 2] - Twig Extensions - Create a Twig Extension Function to Keep DRY 05:55