Home / Symfony / New in Twig 4.0: A New for Loop for Twig 4.0

New in Twig 4.0: A New for Loop for Twig 4.0


Fabien Potencier
Contributed by
Fabien Potencier
in
#4131
, #4134
, #4135
, #4141
, #4153
, #4169
and #4251

The for tag has been part of Twig since the very first release, back in
2009, and its loop variable has barely changed since. Seventeen years is
a long time. For Twig 4.0, I rebuilt the whole loop machinery: loop.last
now works with any iterator, new helpers replace the bookkeeping code we
have all written a hundred times, the if condition makes a comeback, and
recursive loops become a first-class feature.

The Problem: A loop Variable Full of Holes

In Twig 3, the values exposed by loop are computed once, before the
loop starts. That detail leaks into your templates: loop.last,
loop.length, loop.revindex, and loop.revindex0 only exist when
the sequence can be counted upfront, an array or a Countable object.
Loop over a generator or a database result iterator, and they quietly
disappear:

{# "users" is a generator, not an array #}
{% for user in users %}
    {{ user.username }}{% if not loop.last %}, {% endif %}
{% endfor %}

{# Twig 3 outputs a trailing comma: Lucas, Hélène, Thomas, #}

It works on your machine, where users is a plain array. Then someone
optimizes the code to stream users straight from the database, and
production greets you with a trailing comma: loop.last evaluates to
null, so the condition is always true. With strict_variables
enabled, you get an error instead, but not a helpful one:

Key "last" for sequence/mapping with keys "parent, index0, index,
first" does not exist in "user_list.html.twig" at line 4.

Not great either way. Twig 4.0 closes this gap, and takes the opportunity
to make the loop context much more capable.

loop.last Now Works with Any Iterator

In Twig 4, the loop reads one item ahead, so loop.last is always
available, whatever you iterate on: arrays, generators, any
Traversable. The snippet above renders Lucas, Hélène, Thomas. No
trailing comma, no surprise.

What about loop.length? Counting a generator would mean consuming it,
so it still requires a countable sequence; same for loop.revindex and
loop.revindex0. But instead of behaving like undefined variables, they
now fail with an error that tells you what is actually going on:

The "loop.length" variable is not defined as the loop iterates on a
non-countable iterator in "user_list.html.twig" at line 4.

Two related fixes complete the picture: loop behaves correctly in the
else clause of a loop, and IteratorAggregate objects are iterated
as expected.

loop.previous and loop.next

Reading one item ahead enables more than a working loop.last. Two new
variables give you access to the values surrounding the current one:

{% for value in values %}
    {% if not loop.first and value > loop.previous %}
        The value increased!
    {% endif %}
    {{ value }}
{% endfor %}

They work with generators too.

Grouping with loop.changed()

You know this template; we have all written it:

{# Twig 3 #}
{% set previousCategory = null %}
{% for entry in entries %}
    {% if entry.category != previousCategory %}
        <h2>{{ entry.category }}</h2>
        {% set previousCategory = entry.category %}
    {% endif %}
    <p>{{ entry.message }}</p>
{% endfor %}

Tracking the previous category by hand works, but it is super verbose. The new
loop.changed() method returns true whenever it is called with a value
different from the previous call:

{# Twig 4 #}
{% for entry in entries %}
    {% if loop.changed(entry.category) %}
        <h2>{{ entry.category }}</h2>
    {% endif %}
    <p>{{ entry.message }}</p>
{% endfor %}

Cycling with loop.cycle()

Alternating between values used to go through the global cycle()
function, which needs to be told the current position explicitly:

{# Twig 3 #}
{% for row in rows %}
    <li class="{{ cycle(['odd', 'even'], loop.index0) }}">{{ row }}</li>
{% endfor %}

The loop now does it natively and keeps track of the position for you:

{# Twig 4 #}
{% for row in rows %}
    <li class="{{ loop.cycle('odd', 'even') }}">{{ row }}</li>
{% endfor %}

The if Condition Is Back

Twig 3.0 removed the if condition on for tags. The filter filter is
supposed to be a replacement but as it runs before the loop starts, the
condition cannot depend on something that changes inside the loop body, nor on
the loop variable itself. Twig 4 brings the condition back:

{% for user in users if user.active %}
    <li>{{ user.username }}</li>
{% endfor %}

My advice: use |filter when the condition only depends on each item,
and if when it depends on the loop state or on variables updated in the
body:

{% set stopOnFabien = false %}
{% for user in users if not stopOnFabien %}
    - {{ user }}
    {% set stopOnFabien = user == 'Fabien' %}
{% endfor %}

Recursive Loops

Jinja, Twig’s Python cousin, has had recursive loops forever. In Twig, we
emulated them with a macro calling itself through _self:

{# Twig 3 #}
{% macro menu(items) %}
    <ul>
        {% for item in items %}
            <li>{{ item.title }}
                {% if item.children %}
                    {{ _self.menu(item.children) }}
                {% endif %}
            </li>
        {% endfor %}
    </ul>
{% endmacro %}

{{ _self.menu(sitemap) }}

It works, but defining a macro to render a sitemap always felt like too
much ceremony to me. Twig 4 makes recursion part of the loop itself: call
the loop() function with a nested sequence and the loop body is
re-executed for it:

{# Twig 4 #}
<ul class="sitemap">
    {% for item in sitemap %}
        <li>{{ item.title }}
            {% if item.children %}
                <ul>{{ loop(item.children) }}</ul>
            {% endif %}
        </li>
    {% endfor %}
</ul>

Two companion variables, loop.depth and loop.depth0, expose the
current nesting level. And because infinite recursion is always one bug
away, a built-in guard stops it with a Nesting level too deep. error
after 50 levels, before it takes your server down with it.

The Upgrade Path

The for rework introduces no deprecations on the 3.x branch: a
template that runs deprecation-free on Twig 3 renders identically on Twig
4.0, with the lone exception that reading loop.length,
loop.revindex, or loop.revindex0 on a non-countable iterator now
raises a clear error instead of producing empty output. The new loop
features are exclusive to Twig 4.0, available today as an alpha release;
one more reason to give it a spin.


Sponsor the Symfony project.
Tagged:

Leave a Reply

Your email address will not be published. Required fields are marked *