bug #4923 Fix wrapping the Twig cache pool in a second tag aware adapter (nicolas-grekas)

This PR was merged into the 3.x branch.

Discussion
----------

Fix wrapping the Twig cache pool in a second tag aware adapter

`twig/extra-bundle` wires the `{% cache %}` pool like this:

```php
->set('twig.cache', TagAwareAdapter::class)
    ->args([$service('.twig.cache.inner')])

->set('.twig.cache.inner')
    ->parent('cache.app')
    ->tag('cache.pool', ['name' => 'twig.cache'])
```

`.twig.cache.inner` is a child of `cache.app`, so when an application configures
`framework.cache.app` with a natively tag aware adapter (`cache.adapter.redis_tag_aware`,
or the valkey, pdo and mongodb variants), the child pool is *already* a
`TagAwareAdapterInterface` and `twig.cache` wraps it in a second `TagAwareAdapter`.

Two nested `TagAwareAdapter`s never read back what they wrote:

```php
$pool = new TagAwareAdapter(new TagAwareAdapter(new ArrayAdapter()));

$item = $pool->getItem('k');
$item->set('value')->tag('t1');
$pool->save($item);

var_dump($pool->getItem('k')->isHit()); // bool(false)
```

So every `{% cache %}` block silently misses, on every request. This was reported here as
twigphp/Twig#3636 (the workaround in that thread is to redefine `twig.cache` and
`.twig.cache.inner` by hand as a `RedisTagAwareAdapter`), and again on the Symfony side as
symfony/symfony#54339 by `@rpkamp`. Symfony first tried to fix it in symfony/symfony#57927 by
deprecating passing a tag aware pool to `TagAwareAdapter`, but `@keulinho`'s analysis in
symfony/symfony#58830 showed that the deprecated thing was not the culprit, so that
deprecation was reverted in symfony/symfony#58950 and the defect was left where it is: in
this bundle. Symfony's own cache wiring already gets this right, it aliases
`cache.app.taggable` to `cache.app` when the configured adapter is natively tag aware
instead of decorating it.

## The fix

A small compiler pass resolves the parent chain of `.twig.cache.inner` and, when the
adapter it ends up on implements `TagAwareAdapterInterface`, drops the decorator and
aliases `twig.cache` to the pool. Nothing changes for the common case of a plain
`cache.app`, where the decorator is what makes the pool taggable and stays in place.

I picked this over declaring the pool through `framework.cache.pools` (with `tags: true`,
which would let Symfony make the same decision) because the pool is only registered when
the `cache` extension is enabled, and reproducing that condition inside a `prepend()` call
means processing the bundle's own configuration before `load()` runs. The compiler pass
stays entirely inside the bundle and only acts when `twig.cache` exists.

Detecting tag awareness from the resolved adapter class rather than from a list of adapter
ids keeps this working across `^5.4|^6.4|^7.0|^8.0` even though the set of natively tag
aware adapters grew over that range, and it also covers a `cache.app` overridden with a
custom tag aware adapter.

Two constraints are covered by the tests: `twig.cache` keeps its own namespace instead of
collapsing into the application pool, and the three argument aliases
(`TagAwareCacheInterface $twigCache`, `CacheInterface $twigCache`,
`CacheItemPoolInterface $twigCache`) keep resolving.

## Verification

Compiled containers with `framework.cache.app` left at its default and set to
`cache.adapter.redis_tag_aware`, before and after the patch:

| `framework.cache.app` | before | after |
| --- | --- | --- |
| default (filesystem) | `TagAwareAdapter(FilesystemAdapter)` | unchanged |
| `cache.adapter.redis_tag_aware` | `TagAwareAdapter(RedisTagAwareAdapter)` | `RedisTagAwareAdapter` |
| `cache.adapter.valkey_tag_aware` | `TagAwareAdapter(RedisTagAwareAdapter)` | `RedisTagAwareAdapter` |
| `cache.adapter.redis` | `TagAwareAdapter(RedisAdapter)` | unchanged |

In the fixed tag aware case the pool namespace stays distinct from `cache.app`'s.

`extra/twig-extra-bundle` test suite, on Symfony 8.2-dev and on framework-bundle 6.4:

```
PHPUnit 9.6.36 by Sebastian Bergmann and contributors.

Testing
...................                                               19 / 19 (100%)

Time: 00:00.534, Memory: 28.00 MB

OK (19 tests, 100 assertions)
```

The two new tests fail without the pass:

```
1) TwigCachePoolPassTest::testThePoolIsNotDecoratedWhenTheAppAdapterIsTagAware
Failed asserting that two strings are identical.
-'@.twig.cache.inner'
+'Symfony\Component\Cache\Adapter\TagAwareAdapter'
```

Commits
-------

bd939c8c3a Fix wrapping the Twig cache pool in a second tag aware adapter
This commit is contained in:
Fabien Potencier
2026-09-11 05:29:58 -07:00
5 changed files with 253 additions and 0 deletions
+1
View File
@@ -1,5 +1,6 @@
# 3.29.0 (2026-XX-XX)
* Fix `{% cache %}` always missing in the Symfony bundle when `framework.cache.app` uses a natively tag aware adapter
* Fix the sandbox resolving `use` trait templates before checking that the `use` tag is allowed
* Fix `html_attr` dropping `style` declarations whose value is `0`, `0.0` or `'0'`
* Fix the `default` filter fallback emitting an undefined variable warning when it uses the null-safe operator
@@ -0,0 +1,57 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler;
use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Drops the "twig.cache" TagAwareAdapter when the pool it decorates is already tag aware.
*
* Wrapping a tag aware adapter in a TagAwareAdapter makes every read a miss, so the
* decorator must go when "cache.app" uses a natively tag aware adapter, the same way
* Symfony aliases "cache.app.taggable" to "cache.app" instead of decorating it.
*
* @internal
*/
final class TwigCachePoolPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('twig.cache') || !$container->hasDefinition('.twig.cache.inner')) {
return;
}
$definition = $container->getDefinition('.twig.cache.inner');
$class = $definition->getClass();
while (null === $class && $definition instanceof ChildDefinition) {
$parent = $definition->getParent();
if (!$container->hasDefinition($parent) && !$container->hasAlias($parent)) {
return;
}
$definition = $container->findDefinition($parent);
$class = $definition->getClass();
}
if (!is_a($class ?? '', TagAwareAdapterInterface::class, true)) {
return;
}
$container->removeDefinition('twig.cache');
$container->setAlias('twig.cache', '.twig.cache.inner');
}
}
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\TwigExtraBundle\Tests\DependencyInjection\Compiler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
use Symfony\Component\Filesystem\Filesystem;
use Twig\Extra\TwigExtraBundle\Tests\Fixture\CacheKernel;
class TwigCachePoolPassTest extends TestCase
{
/** @var CacheKernel[] */
private array $kernels = [];
protected function tearDown(): void
{
foreach ($this->kernels as $kernel) {
(new Filesystem())->remove($kernel->getTempDir());
}
$this->kernels = [];
}
public function testThePoolIsDecoratedWhenTheAppAdapterIsNotTagAware(): void
{
$pools = $this->compileFor(null);
$this->assertSame(TagAwareAdapter::class, $pools['twig.cache']);
$this->assertSame(FilesystemAdapter::class, $pools['.twig.cache.inner']);
$this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']);
}
public function testThePoolIsNotDecoratedWhenTheAppAdapterIsTagAware(): void
{
$pools = $this->compileFor('cache.adapter.redis_tag_aware');
$this->assertSame('@.twig.cache.inner', $pools['twig.cache']);
$this->assertSame(RedisTagAwareAdapter::class, $pools['.twig.cache.inner']);
$this->assertNotSame($pools['cache.app.namespace'], $pools['.twig.cache.inner.namespace']);
}
private function compileFor(?string $cacheAdapter): array
{
$this->kernels[] = $kernel = new CacheKernel($cacheAdapter);
$kernel->boot();
return $kernel->getContainer()->getParameter('twig_extra.test.cache_pools');
}
}
@@ -0,0 +1,129 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\Extra\TwigExtraBundle\Tests\Fixture;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Bundle\FrameworkBundle\Test\NotificationAssertionsTrait;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
/**
* Compiles a container for a given "framework.cache.app" adapter and exposes how the
* Twig cache pool ended up being wired in the "twig_extra.test.cache_pools" parameter.
*/
class CacheKernel extends BaseKernel
{
use MicroKernelTrait;
private string $dir;
public function __construct(private ?string $cacheAdapter = null)
{
$this->dir = sys_get_temp_dir().'/twig-extra-bundle/'.bin2hex(random_bytes(6));
parent::__construct('test', false);
}
public function getCacheDir(): string
{
return $this->dir.'/cache';
}
public function getLogDir(): string
{
return $this->dir.'/log';
}
public function getProjectDir(): string
{
return __DIR__;
}
public function getTempDir(): string
{
return $this->dir;
}
public function registerBundles(): iterable
{
yield new FrameworkBundle();
yield new TwigBundle();
yield new TwigExtraBundle();
}
protected function build(ContainerBuilder $container): void
{
$container->addCompilerPass(new class implements CompilerPassInterface {
public function process(ContainerBuilder $container): void
{
$pools = [];
foreach (['twig.cache', '.twig.cache.inner', 'cache.app'] as $id) {
if ($container->hasAlias($id)) {
$pools[$id] = '@'.$container->getAlias($id);
continue;
}
$definition = $container->getDefinition($id);
$pools[$id] = $definition->getClass();
// cache adapters take their namespace as their first string argument
foreach ($definition->getArguments() as $argument) {
if (\is_string($argument)) {
$pools[$id.'.namespace'] = $argument;
break;
}
}
}
$container->setParameter('twig_extra.test.cache_pools', $pools);
}
}, PassConfig::TYPE_BEFORE_REMOVING, -1000);
}
protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void
{
$config = [
'secret' => 'S3CRET',
'router' => ['utf8' => true],
'http_method_override' => false,
'php_errors' => [
'log' => true,
],
];
// the "handle_all_throwables" option was introduced in FrameworkBundle 6.2 (and so was the NotificationAssertionsTrait)
if (trait_exists(NotificationAssertionsTrait::class)) {
$config['handle_all_throwables'] = true;
}
if (null !== $this->cacheAdapter) {
$config['cache'] = ['app' => $this->cacheAdapter];
}
$c->loadFromExtension('framework', $config);
$c->loadFromExtension('twig', [
'default_path' => __DIR__.'/views',
]);
}
protected function configureRoutes($routes): void
{
}
}
@@ -11,10 +11,12 @@
namespace Twig\Extra\TwigExtraBundle;
use Symfony\Component\DependencyInjection\Compiler\PassConfig;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\HttpKernel\KernelInterface;
use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\MissingExtensionSuggestorPass;
use Twig\Extra\TwigExtraBundle\DependencyInjection\Compiler\TwigCachePoolPass;
if (method_exists(KernelInterface::class, 'getShareDir')) {
class TwigExtraBundle extends Bundle
@@ -24,6 +26,8 @@ if (method_exists(KernelInterface::class, 'getShareDir')) {
parent::build($container);
$container->addCompilerPass(new MissingExtensionSuggestorPass());
// priority 64 so that it runs before Symfony's CachePoolPass (priority 32)
$container->addCompilerPass(new TwigCachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 64);
}
}
} else {
@@ -35,6 +39,8 @@ if (method_exists(KernelInterface::class, 'getShareDir')) {
parent::build($container);
$container->addCompilerPass(new MissingExtensionSuggestorPass());
// priority 64 so that it runs before Symfony's CachePoolPass (priority 32)
$container->addCompilerPass(new TwigCachePoolPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 64);
}
}
}