Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Go Deeper: Vars, Twig merge & Form Functions

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Look closer at the Bootstrap help feature: to be nice to screen readers, we should add an aria-describedby attribute to the field that points to an id that we add to the help span:

<label class="sr-only" for="inputHelpBlock">Input with help text</label>
<input type="text" id="inputHelpBlock" class="form-control" aria-describedby="helpBlock">
...
<span id="helpBlock" class="help-block">
    A block of help text that breaks onto a new line and may extend beyond one line.
</span>

That way, when a screen reader focuses on the text box, it will read the help text, which is pretty rad. It also turns out that pulling this off is a cool challenge!

Let's start with a plan: when the form_widget() function is called inside form_row:

... lines 1 - 2
{% block form_row -%}
... line 4
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}">
... line 6
{{- form_widget(form) -}}
... lines 8 - 11
</div>
{%- endblock form_row %}
... lines 14 - 26

we want the attr variable to have a new key called aria-describedby. We've seen magic like this before: in the Bootstrap layout, the form_widget_simple block modifies the attr variable before calling the parent block:

... lines 1 - 4
{% block form_widget_simple -%}
{% if type is not defined or type not in ['file', 'hidden'] %}
{%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
... lines 11 - 246

That's what we want to do!

Modifying the attr variable

Back in our block, before form_widget(), add another if help|default. Inside, set attr = attr|merge() with an array argument. The core merge filter will array_merge() the argument back into the attr variable. Add aria-describedby set to... well, nothing yet:

... lines 1 - 2
{% block form_row -%}
... line 4
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}">
... line 6
{% if help|default %}
{# set the aria-describedby attribute #}
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%}
{% endif %}
... lines 11 - 15
</div>
{%- endblock form_row %}
... lines 18 - 30

First, we need to give our help span an id. Do that: set it to help-block- then print the id variable to make sure this is unique:

... lines 1 - 2
{% block form_row -%}
... line 4
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}">
... line 6
{% if help|default %}
{# set the aria-describedby attribute #}
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%}
{% endif %}
... line 11
{% if help|default %}
<span class="help-block" id="help-block-{{ id }}">{{ help }}</span>
{% endif %}
... line 15
</div>
{%- endblock form_row %}
... lines 18 - 30

The id variable will become the id attribute on the field itself.

Now set the aria-describedby to help-block-, a ~ then id:

... lines 1 - 2
{% block form_row -%}
... line 4
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}">
... line 6
{% if help|default %}
{# set the aria-describedby attribute #}
{%- set attr = attr|merge({'aria-describedby': 'help-block-'~id }) -%}
{% endif %}
... lines 11 - 15
</div>
{%- endblock form_row %}
... lines 18 - 30

The ~ is Twig's rarely-used concatenation operator, so, it's like . in PHP.

Ok! Now that attr is changed before we call form_widget, it'll hopefully render on that widget. Time to give it a try. Refresh!

Ok, go dig into the source to see if the attribute is there. Umm... it's not! There is not any aria-describedby. This tutorial is a LIE!

To Pass or Not Pass Variables

No no, it's cool. It turns out that there's a very subtle, but important detail that I'm neglecting. Let me show you: click to open the parent form_div_layout.html.twig template. We're letting Symfony guess this, but the speciesCount is a NumberType, meaning it'll render as an <input type="number" /> field:

... lines 1 - 13
class GenusFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
... lines 19 - 26
->add('speciesCount')
... lines 28 - 39
;
}
... lines 42 - 48
}

Inside the layout file, find the number_widget block that renders this:

... lines 1 - 133
{%- block number_widget -%}
{# type="number" doesn't work with floats #}
{%- set type = type|default('text') -%}
{{ block('form_widget_simple') }}
{%- endblock number_widget -%}
... lines 139 - 372

Ok, check it out: it sets a type variable, and then calls the form_widget_simple block. Then, when form_widget_simple executes, type is set to number:

... lines 1 - 10
{%- block form_widget_simple -%}
{%- set type = type|default('text') -%}
<input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
{%- endblock form_widget_simple -%}
... lines 15 - 372

So, why does that work, but not our code? Well look at that code again: it sets a variable and then calls the block function. When you call block(), all variables flow through to that block.

But now check out our code: we set a variable, but then we don't execute a block! We call form_widget(). Hey! that's a form rendering function - the same kind that we use inside our normal templates. In this case, the variables do not magically flow through. But that's ok! We already know how to pass variables into form_widget(). Add a second argument, and pass attr set to attr:

... lines 1 - 2
{% block form_row -%}
... line 4
<div class="form-group {% if (not compound or force_error|default(false)) and not valid %} has-error{% endif %}{{ showErrorIcon ? ' has-feedback' : '' }}">
... lines 6 - 10
{{- form_widget(form, {
'attr': attr
}) -}}
... lines 14 - 17
</div>
{%- endblock form_row %}
... lines 20 - 32

Let's refresh! Inspect the field, well, not any field - inspect the isPublished field. This time, we got it!

So not only are you a form-theming pro, but you're quickly becoming a Twig all star.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial is built on Symfony 3 but form theming hasn't changed much in Symfony 4 and Symfony 5. Other than some path differences - this tutorial should work fine.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "symfony/symfony": "3.4.*", // v3.4.49
        "doctrine/orm": "^2.5", // 2.7.5
        "doctrine/doctrine-bundle": "^1.6", // 1.12.13
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.4.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.7
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.23.0
        "sensio/distribution-bundle": "^5.0", // v5.0.25
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.4
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "knplabs/knp-markdown-bundle": "^1.4", // 1.9.0
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
        "stof/doctrine-extensions-bundle": "^1.2" // v1.3.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.7
        "symfony/phpunit-bridge": "^3.0", // v3.4.47
        "nelmio/alice": "^2.1", // v2.3.6
        "doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
    }
}
userVoice