[Part 2] - Twig Extensions - Create a Twig Extension Function to Keep DRY


Towards the end of the previous video we had a basic working implementation that allowed us to add a CSS class (error) if we had one or more validation errors for the current form field.

In this video we will look at a way to customise the CSS class to be either error by default, or some other custom value if we prefer.

One way we could do this is to really hack at the set attr = ... line we already had:

{# /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('') ~ ' ' ~ (attr.class_for_errors|default(' error')))|trim}) %}
    {% endif %}
    {{- parent() -}}
{%- endblock form_widget_simple %}

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

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

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

Throwing custom CSS into the mix bumps up the amount of cases we need to cover. We now want to augment the attr variable to have one of the following values:

  • 'error' when attr.class is not set, and attr.class_for_errors is not something custom
  • {value of class_for_errors} whenattr.classis not set, andattr.class_for_errors` is something custom
  • {value of class} error when attr.class is set, and attr.class_for_errors is not something custom
  • {value of class} {value of class_for_errors} when attr.class is set, and attr.class_for_errors is something custom

Whilst the above set attr = ... line does work, it's got the look of something that in a few weeks time, will have you cursing yourself for ever adding to your code.

Lets instead extract this logic out to a separate custom Twig function.

Custom Twig Function

Rather than that long one liner, I want to keep the logic but move it into a PHP class.

Twig allows us to do this with Twig Extensions.

We're going to be making use of Twig's \Twig_Function class to allow us to, as you might have guessed, create our own function that we can call from our Twig templates.

It's easier than it sounds:

<?php

// /src/AppBundle/Twig/Extension/FormFieldErrorExtension.php

namespace AppBundle\Twig\Extension;

class FormFieldErrorExtension extends \Twig_Extension
{
    public function getFunctions()
    {
        return [
            new \Twig_SimpleFunction('merge_errors_with_custom_error_css', [$this, 'mergeErrorsWithCustomErrorCss']),
        ];
    }

    public function mergeErrorsWithCustomErrorCss(array $attrs = [])
    {
        if (count($attrs) === 0) {
            return $attrs;
        }

        if (false === isset($attrs['class'])) {
            $attrs['class'] = '';
        }

        if (isset($attrs['class_for_errors'])) {
            $attrs['class'] .= $attrs['class_for_errors'];
        }

        return $attrs;
    }
}

Heads up: We're using Symfony 3.3 in this project. This means with the default autoconfiguration setting, we don't need to do any service configuration here. Symfony will figure out that we've created a Twig Extension (because we've extends \Twig_Extension) and will tag our automatically created service accordingly.

If you're not using Symfony 3.3, or you have autoconfiguration disabled, then make sure to create a service definition, and tag accordingly:

# app/config/services.yml

services:
    # Extra service config should not be needed in Symfony 3.3+
    crv.twig.extension.filter_array:
        class: AppBundle\Twig\Extension\FormFieldErrorExtension
        tags: ['twig.extension']

Ok, so to break this down:

    public function getFunctions()
    {
        return [
            new \Twig_SimpleFunction('merge_errors_with_custom_error_css', [$this, 'mergeErrorsWithCustomErrorCss']),
        ];
    }

getFunctions will be called by Symfony behind the scenes. This function tells Symfony what custom Twig functions we want to make available inside our Twig templates.

Notice this function is getFunctions. If we were creating a custom Twig filter we would need getFilters. If creating a custom Twig test we'd need getTests, and so on. A good IDE like PhpStorm will give you a useful breakdown if you use the 'Code > Override Methods` option.

getFunctions return an array.

This means this one FormFieldErrorExtension file can contain multiple custom functions. Here we only define one, but we still need to return an array.

Our only entry is a new \Twig_SimpleFunction object, where merge_errors_with_custom_error_css will be our function name, and the method mergeErrorsWithCustomErrorCss will be called in the current class ($this) when this function is invoked.

To call the function, we simply need to wrap a function call in Twig braces as you might expect:

{% merge_errors_with_custom_error_css([]) %}

Note here if we were using a filter then we would use different syntax, e.g. attr|merge({... where merge is the filter.

The logic for mergeErrorsWithCustomErrorCss is exactly as before:

    public function mergeErrorsWithCustomErrorCss(array $attrs = [])
    {
        if (count($attrs) === 0) {
            return $attrs;
        }

        if (false === isset($attrs['class'])) {
            $attrs['class'] = '';
        }

        if (isset($attrs['class_for_errors'])) {
            $attrs['class'] .= $attrs['class_for_errors'];
        }

        return $attrs;
    }

To call this function from our template we might do this:

{% block textarea_widget -%}
    {% if errors|length > 0 -%}
        {% set attr = merge_errors_with_custom_error_css(attr) %}
    {% endif %}
    {{- parent() -}}
{%- endblock textarea_widget %}

Here, attr is the array we've been working on throughout.

It might be an empty array, so we check: (count($attrs) === 0), and if so, we just return the empty array.

Next we check if attr.class is set. If not, we set it to a default value:

if (false === isset($attrs['class'])) {
    $attrs['class'] = '';
}

We must do this, as we always want a class attribute to make this process work.

if (isset($attrs['class_for_errors'])) {
    $attrs['class'] .= $attrs['class_for_errors'];
}

Next we check if $attrs['class_for_errors'] is set. If not, disregard.

Given that we know we definitely have a $attrs['class'] set, we can then concat .= with whatever is set in $attrs['class_for_errors'].

At this point we return the modified $attrs array.

We could now modify the template accordingly:

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

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

{% block form_widget_simple -%}
    {% if errors|length > 0 -%}
        {% set attr = merge_errors_with_custom_error_css(attr) %}
    {% endif %}
    {{- parent() -}}
{%- endblock form_widget_simple %}

{% block textarea_widget -%}
    {% if errors|length > 0 -%}
        {% set attr = merge_errors_with_custom_error_css(attr) %}
    {% endif %}
    {{- parent() -}}
{%- endblock textarea_widget %}

{% block choice_widget_collapsed -%}
    {% if errors|length > 0 -%}
        {% set attr = merge_errors_with_custom_error_css(attr) %}
    {% endif %}
    {{- parent() -}}
{%- endblock choice_widget_collapsed %}

{% block checkbox_widget -%}
    {% if errors|length > 0 -%}
        {% set attr = merge_errors_with_custom_error_css(attr) %}
    {% endif %}
    {{- parent() -}}
{%- endblock checkbox_widget %}

{% block radio_widget -%}
    {% if 'radio-inline' not in parent_label_class %}
        {% if errors|length > 0 -%}
            {% set attr = merge_errors_with_custom_error_css(attr) %}
        {% endif %}
    {% endif %}
    {{- parent() -}}
{%- endblock radio_widget %}

There's still a bunch of repetition of the method calls, but not the logic has been de-duped.

What this means is we can update the data-custom-error-css-class inside WidgetType to allow us to pass through custom CSS on a per field basis:

<?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', [
                'attr' => [
                    'data-custom-error-css-class' => 'different-error',
                ]
            ])
            ->add('submit', SubmitType::class)
        ;
    }

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

And that's about it.

Dump Is Your Friend

One key point is that every variable you can access from inside a Twig template can be seen by using {{ dump()) }}.

Add this in any template, refresh the page, and your web debug toolbar will contain the output of all available variables.

Remember to remove any extraneous dump statements before deploying, as its use will cause a 500 error in production (app.php):

[2017-09-29 14:32:52] request.CRITICAL: Uncaught PHP Exception Twig_Error_Syntax: "Unknown "dump" function." at /Users/Shared/Development/validation-exploration/app/Resources/views/custom-form-error.html.twig line 5 {"exception":"[object] (Twig_Error_Syntax(code: 0): Unknown \"dump\" function. at /Users/Shared/Development/validation-exploration/app/Resources/views/custom-form-error.html.twig:5)"} []

Once you know you can dump out all the variables, you can start figuring out interesting ways to achieve your goals.

You can add a {{ dump() }} call directly into any of your existing Twig templates, and take a look at the output:

twig dump output

Code For This Video

Get the code for this video.

Episodes