From bd939c8c3af9cd5801d3ae0e241b358cf47ad6d2 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 11 Sep 2026 10:59:50 +0200 Subject: [PATCH] Fix wrapping the Twig cache pool in a second tag aware adapter The ".twig.cache.inner" pool is a child of "cache.app". When the application configures a natively tag aware adapter for it (redis, valkey, pdo or mongodb), the child pool is already tag aware and decorating it with a TagAwareAdapter turns every read into a miss. Alias "twig.cache" to the pool in that case, the way Symfony aliases "cache.app.taggable" to "cache.app" instead of decorating it. The pool keeps its own namespace, so it does not start sharing the application pool's one, and the decorator stays in place for the plain adapters that need it. --- CHANGELOG | 1 + .../Compiler/TwigCachePoolPass.php | 57 ++++++++ .../Compiler/TwigCachePoolPassTest.php | 60 ++++++++ .../Tests/Fixture/CacheKernel.php | 129 ++++++++++++++++++ extra/twig-extra-bundle/TwigExtraBundle.php | 6 + 5 files changed, 253 insertions(+) create mode 100644 extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php create mode 100644 extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php create mode 100644 extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php diff --git a/CHANGELOG b/CHANGELOG index 2dd9742af..ebb942cc4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 diff --git a/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php b/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php new file mode 100644 index 000000000..a5bb216fd --- /dev/null +++ b/extra/twig-extra-bundle/DependencyInjection/Compiler/TwigCachePoolPass.php @@ -0,0 +1,57 @@ +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'); + } +} diff --git a/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php b/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php new file mode 100644 index 000000000..559215ab1 --- /dev/null +++ b/extra/twig-extra-bundle/Tests/DependencyInjection/Compiler/TwigCachePoolPassTest.php @@ -0,0 +1,60 @@ +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'); + } +} diff --git a/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php b/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php new file mode 100644 index 000000000..18dc54c86 --- /dev/null +++ b/extra/twig-extra-bundle/Tests/Fixture/CacheKernel.php @@ -0,0 +1,129 @@ +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 + { + } +} diff --git a/extra/twig-extra-bundle/TwigExtraBundle.php b/extra/twig-extra-bundle/TwigExtraBundle.php index 495785623..3475eefc2 100644 --- a/extra/twig-extra-bundle/TwigExtraBundle.php +++ b/extra/twig-extra-bundle/TwigExtraBundle.php @@ -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); } } }