Commit Graph

7822 Commits

Author SHA1 Message Date
Fabien Potencier ab9efdeff3 Run extension tests with PHPUnit directly and fail on deprecations 2026-10-25 07:36:58 +01:00
Fabien Potencier 4bfa0d9141 tidy #5015 Fail CI when an extension test job fails (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Fail CI when an extension test job fails

Commits
-------

e418eb9883 Fail CI when an extension test job fails
2026-10-25 07:36:12 +01:00
Fabien Potencier e418eb9883 Fail CI when an extension test job fails 2026-10-25 07:28:03 +01:00
Fabien Potencier 9e86965e41 tidy #5012 Remove the numeric locale switch when compiling number literals (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Remove the numeric locale switch when compiling number literals

`Compiler::repr()` switches `LC_NUMERIC` to `C` and back around every number it compiles. This was added when `repr()` used `strval()`: before PHP 8, `strval()` printed floats with the locale's decimal separator, so a French locale produced `1,2` in compiled templates.

`repr()` now uses `var_export()`, which always uses `.` whatever the locale. Since PHP 8.0, float-to-string conversion no longer depends on the locale either. With Twig 3.x requiring PHP 8.1, the locale switch never changes the output, so this PR removes it.

Alternative to #5003

Commits
-------

83a4f669db Remove the numeric locale switch when compiling number literals
2026-10-23 17:38:03 +02:00
Fabien Potencier 83a4f669db Remove the numeric locale switch when compiling number literals 2026-10-22 11:37:13 -07:00
Fabien Potencier a50e9b7fe0 feature #4997 Add the collate function to compare strings in the alphabetical order of a locale (fabpot)
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Add the collate function to compare strings in the alphabetical order of a locale

Alternative to #4943

Instead of adding a new `sort_localized` filter, a new `collate()` function, part of `twig/intl-extra`, allows to compares two strings using the alphabetical order of a locale:

```twig
{# athlétisme, Boxe, Échecs, équitation, judo, Yoga #}
{{ sports|sort((a, b) => collate(a, b))|join(', ') }}

{% for city in cities|sort((a, b) => collate(a.name, b.name)) %}
    {{ city.name }}
{% endfor %}

{# cs: hrad, chata, ideal #}
{{ words|sort((a, b) => collate(a, b, 'cs'))|join(', ') }}
```

That avoids the creation of a new function and reuses the existing arrow function.
The current locale is the default, as with every other intl-extra function.

The only drawback I can see is that sorting a plain list is more verbose: `sort((a, b) => collate(a, b))` instead of `sort_localized`.

Commits
-------

43753b0328 Add the collate function to compare strings in the alphabetical order of a locale
2026-10-19 10:08:03 -07:00
Fabien Potencier 43753b0328 Add the collate function to compare strings in the alphabetical order of a locale 2026-10-19 10:08:00 -07:00
Fabien Potencier 428a161790 feature #4989 [3.x] Allow registering an extension class lazily with AttributeExtension (nicolas-grekas)
This PR was merged into the 3.x branch.

Discussion
----------

[3.x] Allow registering an extension class lazily with `AttributeExtension`

When an extension class is registered through `AttributeExtension`, `Environment::getExtension()` returns the `AttributeExtension`, not an instance of the class that was asked for. This PR makes it return the instance provided by the runtime loaders instead. An extension can then be registered lazily, so that its dependencies are created on its first call from a template, without changing what `getExtension()` returns.

`getRuntime()` also falls back to the registered extension of the requested class. An extension can then return the `[self::class, 'method']` callables that `AttributeExtension` builds from its `getFunctions()`, and still work when it's added with `addExtension(new MyExtension(...))`.

Needed for symfony/symfony#66828, which registers Symfony's own extensions this way.

Merge-up to 4.x: only the imports of `tests/EnvironmentTest.php` conflict, and the CHANGELOG entry doesn't go to 4.x.

Commits
-------

6e527a86e0 Allow registering an extension class lazily with `AttributeExtension`
2026-10-16 11:00:11 +02:00
Nicolas Grekas 6e527a86e0 Allow registering an extension class lazily with AttributeExtension 2026-10-16 11:00:03 +02:00
Fabien Potencier 407af8fd32 feature #4991 Allow marking a Markup instance as safe for some escaping strategies only (fabpot)
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Allow marking a Markup instance as safe for some escaping strategies only

Currently, a Markup instance is safe for every escaping strategy.

A new optional third constructor argument lists the strategies the content is safe for: new Markup($html, 'UTF-8', ['html']) is printed as-is in an HTML context and escaped in any other one. The default stays ['all'], so existing code is not affected.

Commits
-------

527710bd71 Allow marking a Markup instance as safe for some escaping strategies only
2026-10-16 10:08:10 +02:00
Fabien Potencier 527710bd71 Allow marking a Markup instance as safe for some escaping strategies only 2026-10-16 10:08:06 +02:00
Fabien Potencier 11417e29b3 feature #4990 Consider a class safe for html_attr also safe for html_attr_relaxed and html at runtime (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Consider a class safe for html_attr also safe for html_attr_relaxed and html at runtime

The compile-time safe analysis already treats a filter or function declared safe for html_attr as also safe for html_attr_relaxed and html, and one declared safe for html_attr_relaxed as also safe for html.

`EscaperRuntime::addSafeClass()` did not apply the same rules, so an object of a class registered as safe for html_attr was still escaped when printed in an HTML context. This applies the same rules at runtime, including to classes that inherit their safety from a parent class or an interface.

Commits
-------

e0e04a1561 Align runtime safe classes with the compile-time strategy subset rules
2026-10-16 09:04:47 +02:00
Fabien Potencier e0e04a1561 Align runtime safe classes with the compile-time strategy subset rules 2026-10-16 09:04:38 +02:00
Fabien Potencier 070adde1b2 bug #4992 Fix reading an attribute through its getter initializing a lazy object when the property is not public (abp002)
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Fix reading an attribute through its getter initializing a lazy object when the property is not public

Fix #4986

With a partially loaded Doctrine entity (native lazy objects), `{{ project.title }}` initializes the whole object, while `{{ project.getTitle() }}` does not. `getAttribute()` first calls `isset($object->title)`. On a private property this is always false when the class has no `__isset()`, but on a lazy object it triggers the initialization.

The property checker now tells when `isset()` can be skipped: non-public or static property, and no `__isset()` method. Classes with `__isset()` keep the current behavior.

This is not a regression of 3.30: 3.28 behaves the same. It shows up once `enable_native_lazy_objects` is enabled in Doctrine.

Commits
-------

cfefc6f6b2 Fix reading an attribute through its getter initializing a lazy object when the property is not public
2026-10-16 09:00:02 +02:00
Alejandro Borrego cfefc6f6b2 Fix reading an attribute through its getter initializing a lazy object when the property is not public 2026-10-16 08:59:58 +02:00
Fabien Potencier fda5a623f9 minor #4984 Speed up applying html_cva() recipes that resolve to classes already seen (Kocal)
This PR was merged into the 3.x branch.

Discussion
----------

Speed up applying `html_cva()` recipes that resolve to classes already seen

`Cva::apply()` splits the resolved classes with `preg_split()` and deduplicates them with `array_unique()` on every call. Components call `html_cva()` on every render and apply the same few variant combinations, so most of that work is repeated: on an admin dashboard built with the Symfony UX Toolkit Shadcn kit, the 486 calls per request resolve to only 38 distinct class strings (92% repeats), around 550 bytes and 37 classes each. Splitting and deduplicating them is about 1.5 µs out of a 2 µs call.

The result now goes into a static array keyed by the resolved class string, the only thing it depends on. The array is static because `html_cva()` creates a new `Cva` on every call. It holds at most 1,000 entries and is emptied when full.

- `Cva::apply()` time per dashboard request (hrtime around the method, cache emptied between requests like PHP-FPM, 6 interleaved runs of 20 requests each side): **1.27-1.35 ms** -> **0.69-0.73 ms** (-46%); with the cache kept between requests, as in a long-running worker: **0.58-0.61 ms**
- standalone benchmark (500 buttons using the Shadcn button recipe, 24 distinct variant/size pairs, cache emptied between renders, 5 interleaved runs each side, identical output): **510-517 ms** -> **432-434 ms** (-16%)
- Blackfire (5 samples of 5 requests): wall time **2.52 s** -> **2.40 s**

On the whole dashboard request, this saves about 0.6 ms out of 46 ms (~1.3%).

```php
<?php

// Run from extra/html-extra: php bench.php
use Twig\Environment;
use Twig\Extra\Html\Cva;
use Twig\Extra\Html\HtmlExtension;
use Twig\Loader\ArrayLoader;

require __DIR__.'/vendor/autoload.php';

// The button recipe of the Symfony UX Toolkit Shadcn kit
$twig = new Environment(new ArrayLoader([
    'button.html.twig' => <<<'TWIG'
        {%- set style = html_cva(
            base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
            variants: {
                variant: {
                    default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
                    outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
                    secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
                    ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
                    destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
                    link: 'text-primary underline-offset-4 hover:underline',
                },
                size: {
                    default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
                    xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
                    sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
                    lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
                    icon: 'size-8',
                    'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
                    'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
                    'icon-lg': 'size-9',
                },
            },
        ) -%}
        <button class="{{ style.apply({variant: variant, size: size}) }}">{{ label }}</button>
        TWIG,
    // 500 buttons, 24 distinct variant/size pairs
    'page.html.twig' => <<<'TWIG'
        {% for i in 1..500 %}{{ include('button.html.twig', {variant: ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'][i % 6], size: ['default', 'sm', 'lg', 'icon'][i % 4], label: 'Button ' ~ i}) }}{% endfor %}
        TWIG,
]));
$twig->addExtension(new HtmlExtension());

// PHP-FPM empties static properties at the end of every request
$render = static function () use ($twig): string {
    if (property_exists(Cva::class, 'classListCache')) {
        (new \ReflectionProperty(Cva::class, 'classListCache'))->setValue(null, []);
    }

    return $twig->render('page.html.twig');
};
$html = $render();

$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($i = 0; $i < 100; ++$i) {
        $render();
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 500 buttons: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html));
```

Blackfire profiles (dashboard page):
- before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/57d327e3-02f6-4d6d-b27c-a39d3ce0438e/graph
- after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/f63c01a9-adab-48bd-a524-67d576ab5ebf/graph
- comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/57d327e3-02f6-4d6d-b27c-a39d3ce0438e...f63c01a9-adab-48bd-a524-67d576ab5ebf/graph

This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.

Commits
-------

9e50ff7083 Speed up applying `html_cva()` recipes that resolve to classes already seen
2026-10-16 08:54:50 +02:00
Hugo Alliaume 9e50ff7083 Speed up applying html_cva() recipes that resolve to classes already seen
`Cva::apply()` splits the resolved classes with `preg_split()` and deduplicates them with `array_unique()` on every call. Components call `html_cva()` on every render and apply the same few variant combinations, so most of that work is repeated: on an admin dashboard built with the Symfony UX Toolkit Shadcn kit, the 486 calls per request resolve to only 38 distinct class strings (92% repeats), around 550 bytes and 37 classes each. Splitting and deduplicating them is about 1.5 µs out of a 2 µs call.

The result now goes into a static array keyed by the resolved class string, the only thing it depends on. The array is static because `html_cva()` creates a new `Cva` on every call. It holds at most 1,000 entries and is emptied when full.

- `Cva::apply()` time per dashboard request (hrtime around the method, cache emptied between requests like PHP-FPM, 6 interleaved runs of 20 requests each side): **1.27-1.35 ms** -> **0.69-0.73 ms** (-46%); with the cache kept between requests, as in a long-running worker: **0.58-0.61 ms**
- standalone benchmark (500 buttons using the Shadcn button recipe, 24 distinct variant/size pairs, cache emptied between renders, 5 interleaved runs each side, identical output): **510-517 ms** -> **432-434 ms** (-16%)
- Blackfire (5 samples of 5 requests): wall time **2.52 s** -> **2.40 s**

On the whole dashboard request, this saves about 0.6 ms out of 46 ms (~1.3%).

```php
<?php

// Run from extra/html-extra: php bench.php
use Twig\Environment;
use Twig\Extra\Html\Cva;
use Twig\Extra\Html\HtmlExtension;
use Twig\Loader\ArrayLoader;

require __DIR__.'/vendor/autoload.php';

// The button recipe of the Symfony UX Toolkit Shadcn kit
$twig = new Environment(new ArrayLoader([
    'button.html.twig' => <<<'TWIG'
        {%- set style = html_cva(
            base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
            variants: {
                variant: {
                    default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
                    outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
                    secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
                    ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
                    destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
                    link: 'text-primary underline-offset-4 hover:underline',
                },
                size: {
                    default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
                    xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
                    sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
                    lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
                    icon: 'size-8',
                    'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
                    'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
                    'icon-lg': 'size-9',
                },
            },
        ) -%}
        <button class="{{ style.apply({variant: variant, size: size}) }}">{{ label }}</button>
        TWIG,
    // 500 buttons, 24 distinct variant/size pairs
    'page.html.twig' => <<<'TWIG'
        {% for i in 1..500 %}{{ include('button.html.twig', {variant: ['default', 'outline', 'secondary', 'ghost', 'destructive', 'link'][i % 6], size: ['default', 'sm', 'lg', 'icon'][i % 4], label: 'Button ' ~ i}) }}{% endfor %}
        TWIG,
]));
$twig->addExtension(new HtmlExtension());

// PHP-FPM empties static properties at the end of every request
$render = static function () use ($twig): string {
    if (property_exists(Cva::class, 'classListCache')) {
        (new \ReflectionProperty(Cva::class, 'classListCache'))->setValue(null, []);
    }

    return $twig->render('page.html.twig');
};
$html = $render();

$times = [];
for ($run = 0; $run < 7; ++$run) {
    $start = hrtime(true);
    for ($i = 0; $i < 100; ++$i) {
        $render();
    }
    $times[] = (hrtime(true) - $start) / 1e6;
}
sort($times);
printf("100 renders of 500 buttons: median %.0f ms (min %.0f, max %.0f) | sha1 %s\n", $times[3], $times[0], $times[6], sha1($html));
```

Blackfire profiles (dashboard page):
- before: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/57d327e3-02f6-4d6d-b27c-a39d3ce0438e/graph
- after: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/f63c01a9-adab-48bd-a524-67d576ab5ebf/graph
- comparison: https://app.blackfire.io/envs/5f4f9a62-eaa0-45ee-b7b3-a1b879f550e9/profiles/compare/57d327e3-02f6-4d6d-b27c-a39d3ce0438e...f63c01a9-adab-48bd-a524-67d576ab5ebf/graph

This dashboard is available at https://github.com/Kocal/sf-ux-perfs-twig-components.
2026-10-16 08:54:29 +02:00
Fabien Potencier 56c74bd5c8 bug #4988 Fix compiled float literals depending on the serialize_precision ini setting (nicolas-grekas)
This PR was merged into the 3.x branch.

Discussion
----------

Fix compiled float literals depending on the serialize_precision ini setting

`Compiler::repr()` writes floats with `var_export()`, which follows the `serialize_precision` ini setting. With `serialize_precision=17`, `0.1` compiles to `0.10000000000000001`, so two servers configured differently compile the same template to different code. This pins the setting to `-1` while dumping a float, the same way the numeric locale is already pinned.

Found while checking that two builds of the same Symfony app give the same cache files.

Commits
-------

e53806e99c Fix compiled float literals depending on the serialize_precision ini setting
2026-10-13 22:26:57 +02:00
Nicolas Grekas e53806e99c Fix compiled float literals depending on the serialize_precision ini setting 2026-10-09 17:38:55 +02:00
Fabien Potencier d79aaf70a5 documentation #4975 Document the backward compatibility promise (fabpot)
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Document the backward compatibility promise

Commits
-------

3f78c6fba4 Document the backward compatibility promise
2026-09-30 22:37:59 +02:00
Fabien Potencier 3f78c6fba4 Document the backward compatibility promise 2026-09-30 22:37:55 +02:00
Fabien Potencier 380406b791 minor #4954 Speed up loading compiled templates that OPcache already holds (nicolas-grekas)
This PR was merged into the 3.x branch.

Discussion
----------

Speed up loading compiled templates that OPcache already holds

`FilesystemCache::load()` calls `is_file()` before including a compiled template, so each template costs a stat() call on each request, even when OPcache already holds the file. This asks `opcache_is_script_cached()` first. On the Symfony Demo in prod, with `opcache.validate_timestamps` on or off, loading a compiled template goes from 8.7µs to 4.7µs, which saves 11µs to 36µs per page for the 3 to 9 templates it renders.

OPcache isn't asked when `FORCE_BYTECODE_INVALIDATION` is set: it can still serve a compiled file removed from disk, while that option wants it compiled again. Without the option, what gets loaded doesn't change: Twig compiles a missing file again today, then `include_once` gets the same entry from OPcache anyway. It isn't asked either when `opcache.restrict_api` is set, since the call warns then, nor when it validates timestamps on each call, since it stats the file anyway then.

Merge-up to 4.x: the CHANGELOG entry doesn't go to 4.x.

Commits
-------

ef3780c2e0 Speed up loading compiled templates that OPcache already holds
2026-09-30 22:20:41 +02:00
Nicolas Grekas ef3780c2e0 Speed up loading compiled templates that OPcache already holds 2026-09-30 22:20:34 +02:00
Fabien Potencier c471d2ad58 minor #4976 Speed up traversing nodes with node visitors (nicolas-grekas)
This PR was merged into the 3.x branch.

Discussion
----------

Speed up traversing nodes with node visitors

`NodeTraverser` walks the whole tree once per node visitor, and it iterated over the children of each node with `foreach ($node as ...)`, which allocates an `ArrayIterator` per node and per visitor. It now reads the children array through a new internal `Node::getNodes()` method. On the Symfony Demo, where six visitors are registered, walking the 21k nodes of its 52 templates takes half the instructions it took, and warming up its Twig cache runs 15% fewer instructions and takes 520ms instead of 566ms.

The traverser doesn't go through `getIterator()` anymore, so a subclass overriding it to hide children from visitors would be bypassed, and one declaring its own `getNodes()` would clash; I found none in Twig, Symfony or UX. Calling `getArrayCopy()` on the iterator returned by `getIterator()` would avoid the new method, but it saves 13% of the traversal instructions instead of 50%: `new \ArrayIterator()` and `getArrayCopy()` both copy the array.

<details>
<summary>Merge-up notes</summary>

To 4.x: the patch applies as is (I ran the 4.x suite with it), the CHANGELOG entry doesn't go to 4.x.

</details>

Commits
-------

69fefec125 Speed up traversing nodes with node visitors
2026-09-30 22:13:13 +02:00
Nicolas Grekas 69fefec125 Speed up traversing nodes with node visitors 2026-09-30 22:13:07 +02:00
Fabien Potencier e7de480373 feature #4951 Allow skipping the directory check when adding paths to FilesystemLoader (nicolas-grekas)
This PR was merged into the 3.x branch.

Discussion
----------

Allow skipping the directory check when adding paths to `FilesystemLoader`

`FilesystemLoader::addPath()` and `prependPath()` call `is_dir()` on each path, so an app built from a compiled container pays for it on each request, even though the paths were checked when the container was built. This adds a third argument to skip the check:

```php
$loader->addPath($templateDir, 'admin', false);
```

It's read with `func_get_arg()` because Drupal and Contao override both methods with two arguments. On the Symfony Demo, where TwigBundle registers 14 paths, building the loader goes from 64µs to 21µs per request once TwigBundle passes `false` (symfony/symfony#66334).

<details>
<summary>Merge-up notes</summary>

To 4.x: declare the argument for real, `bool $check = true`, instead of reading it with `func_get_arg()`; the tests apply as is. The CHANGELOG and `doc/deprecated.rst` entries don't go to 4.x, and the `versionadded` block has to be removed from `doc/api.rst` since the 4.x docs have none.

</details>

Commits
-------

9e1e2e2cea Allow skipping the directory check when adding paths to `FilesystemLoader`
2026-09-30 21:59:42 +02:00
Nicolas Grekas 9e1e2e2cea Allow skipping the directory check when adding paths to FilesystemLoader 2026-09-30 21:16:25 +02:00
Fabien Potencier c72c875b91 bug #4980 Fix the deprecated tag generating invalid PHP for an integer message (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Fix the deprecated tag generating invalid PHP for an integer message

Closes #4978

Commits
-------

c6f1ef9521 Fix the deprecated tag generating invalid PHP for an integer message
2026-09-28 12:38:24 +02:00
Fabien Potencier c6f1ef9521 Fix the deprecated tag generating invalid PHP for an integer message 2026-09-28 12:38:11 +02:00
Fabien Potencier d79ac7e60d bug #4974 Fix the loop variable of a for tag being undefined in the sequence of a nested loop (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Fix the loop variable of a for tag being undefined in the sequence of a nested loop

Commits
-------

0c90971a98 Fix the loop variable of a for tag being undefined in the sequence of a nested loop
2026-09-28 12:26:36 +02:00
Fabien Potencier 3b32fb9231 bug #4979 Reject the spread operator outside sequences, mappings, and call arguments (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Reject the spread operator outside sequences, mappings, and call arguments

Closes #4977

Commits
-------

a04ae6d636 Reject the spread operator outside sequences, mappings, and call arguments
2026-09-28 12:25:34 +02:00
Fabien Potencier a04ae6d636 Reject the spread operator outside sequences, mappings, and call arguments 2026-09-27 19:43:42 +02:00
Fabien Potencier 0c90971a98 Fix the loop variable of a for tag being undefined in the sequence of a nested loop 2026-09-26 10:54:52 +02:00
Fabien Potencier 4241bb73fe bug #4964 Discard what the non-selected branch of a guard tag registers on the parser (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Discard what the non-selected branch of a guard tag registers on the parser

Commits
-------

d5011f4654 Discard what the non-selected branch of a guard tag registers on the parser
2026-09-26 10:43:26 +02:00
Fabien Potencier d5011f4654 Discard what the non-selected branch of a guard tag registers on the parser 2026-09-26 10:43:21 +02:00
Fabien Potencier 14a0e4adb3 bug #4960 Reset the escaping state when compiling a new template after a compilation error (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Reset the escaping state when compiling a new template after a compilation error

Commits
-------

39642e4cdf Reset the escaping state when compiling a new template after a compilation error
2026-09-26 10:41:59 +02:00
Fabien Potencier 39642e4cdf Reset the escaping state when compiling a new template after a compilation error 2026-09-26 10:41:54 +02:00
Fabien Potencier 6ec06d1883 bug #4959 Reject a Template instance created by another environment when resolving templates (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Reject a Template instance created by another environment when resolving templates

Commits
-------

605b1278f7 Reject a Template instance created by another environment when resolving templates
2026-09-26 10:38:49 +02:00
Fabien Potencier 550ac879b3 documentation #4958 3.x doc fixes (fabpot)
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

3.x doc fixes

Commits
-------

1478f9efcd Fix the class name and a missing parenthesis in the runtime loader example
e883536e64 Replace the deprecated twig_test_even function in the custom test example
db3aeb7379 Use the current variable node class in the node tree example
2026-09-26 10:36:51 +02:00
Fabien Potencier 452419d64a documentation #4962 Fix the autoescape documentation about static expressions (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Fix the autoescape documentation about static expressions

Commits
-------

95a176f2bf Fix the autoescape documentation about static expressions
2026-09-26 10:36:25 +02:00
Fabien Potencier c7c9f62b0f bug #4972 Fix the sandbox not reporting the line of a rejected guard tag (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Fix the sandbox not reporting the line of a rejected guard tag

Commits
-------

b1d6b51183 Fix the sandbox not reporting the line of a rejected guard tag
2026-09-26 10:35:30 +02:00
Fabien Potencier acab707eb2 documentation #4973 Use sandbox policy examples that remain meaningful in Twig 4.0 (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Use sandbox policy examples that remain meaningful in Twig 4.0

Commits
-------

677f049e64 Use sandbox policy examples that remain meaningful in Twig 4.0
2026-09-26 10:32:18 +02:00
Fabien Potencier 677f049e64 Use sandbox policy examples that remain meaningful in Twig 4.0 2026-09-26 10:30:06 +02:00
Fabien Potencier e0af798ea9 minor #4965 Rewrite the sandbox documentation around what developers need to know (fabpot)
This PR was merged into the 3.x branch.

Discussion
----------

Rewrite the sandbox documentation around what developers need to know

Commits
-------

9d24327f8d Rewrite the sandbox documentation around what developers need to know
2026-09-26 10:23:05 +02:00
Fabien Potencier b1d6b51183 Fix the sandbox not reporting the line of a rejected guard tag 2026-09-26 10:22:21 +02:00
Fabien Potencier 9d24327f8d Rewrite the sandbox documentation around what developers need to know 2026-09-26 10:02:08 +02:00
Fabien Potencier 1478f9efcd Fix the class name and a missing parenthesis in the runtime loader example 2026-09-25 23:53:05 +02:00
Fabien Potencier e883536e64 Replace the deprecated twig_test_even function in the custom test example 2026-09-25 23:53:05 +02:00
Fabien Potencier db3aeb7379 Use the current variable node class in the node tree example 2026-09-25 23:53:00 +02:00
Fabien Potencier 95a176f2bf Fix the autoescape documentation about static expressions 2026-09-25 23:45:00 +02:00