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.