-
-
Notifications
You must be signed in to change notification settings - Fork 9.8k
Description
Symfony version(s) affected
7.4.x
Description
Originally discovered in 6.4, reproduced in 7.4 as well. This special template prefix was introduced in 3.4.
When a template override uses {% extends '@!Bundle/path/to/template.html.twig' %} to extend the original template, the Twig cache warmer compiles the override but not the original template. This causes runtime compilation errors when the cache directory is read-only.
The @! prefix is documented here: https://symfony.com/doc/current/bundles/override.html#templates
However, there's no mention that the templates reference like this won't be cached during warmup.
How to reproduce
Here is the "bug reproducer" - https://github.com/mbessolov/twig-override-cache-reproducer
Short generic STR:
- Create a template override in
templates/bundles/:
{# templates/bundles/AcmeBundle/Example/template.html.twig #}
{% extends '@!Acme/Example/template.html.twig' %}
{% block content %}
{{ parent() }}
{# Custom content #}
{% endblock %}- Warm up the cache:
bin/console cache:warmup --env=prod- Make the Twig cache read-only (common in containerized/production environments):
chmod -R a-w var/cache/prod/twig- Access a page that renders the overridden template
Expected Behavior
The cache warmer should:
- Detect that the override template references the original template with
@! - Compile both the override AND the original template
- No runtime compilation should be needed
Actual Behavior
- Only the override template is compiled during cache warmup
- The original template (referenced with
@!) is NOT compiled - At runtime, Twig attempts to compile the original template on-the-fly
- If the cache dir is read-only, this fails with:
RuntimeException: Unable to write in the cache directory
[2025-12-10T21:13:24.506728+00:00] request.CRITICAL: Uncaught PHP Exception Twig\Error\RuntimeError:
"An exception has been thrown during the rendering of a template
("Unable to write in the cache directory (/path/to/var/cache/prod/twig/96).")."
at template.html.twig line 1
[previous exception] [object] (RuntimeException(code: 0):
Unable to write in the cache directory (/path/to/var/cache/prod/twig/96).
at /vendor/twig/twig/src/Cache/FilesystemCache.php:57)
Possible Solution
The cache warmer uses TemplateIterator which only discovers templates that exist as files in the filesystem. It doesn't:
- Parse template contents to find dependencies
- Follow
@!prefix references to original templates - Recursively compile template inheritance chains
When a template is overridden, the iterator finds the override (in templates/bundles/) but not the original (in vendor/bundle/Resources/views/), because the original is "shadowed" by the override.
As I see there are a few ways to solve this:
Option 1: Document the limitation
Add clear documentation about this limitation (and possibly some workaround) in the Symfony docs.
Option 2: Warn users
Emit a warning when the cache warmer encounters a template that might reference uncompiled templates.
Option 3: Parse Twig templates before compilation
Extend TemplateIterator to parse Twig source and extract @! references from extends and include tags.
Option 4: Parse compiled templates for @! references
After compiling each template, parse the generated PHP code to find loadTemplate("@!...") calls and compile those templates too.
As a current project-level workaround I came up with something like this (option 3 approach):
<?php
namespace App\CacheWarmer;
use Symfony\Component\Finder\Finder;
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Twig\Environment;
use Twig\Error\Error;
/**
* Automatically discovers and warms up original templates referenced with @! prefix.
*
* This cache warmer scans all template files in the templates/ directory for references
* to original templates using the @! prefix (e.g., {% extends '@!Bundle/...' %}).
* It then explicitly compiles those original templates during cache warmup.
*
* This solves the issue where Symfony's default cache warmer only discovers templates
* that exist as files, but doesn't follow @! references inside those templates.
*/
class AutoDiscoverOriginalTemplateCacheWarmer implements CacheWarmerInterface
{
/**
* Regex pattern to find @! template references in Twig files.
* Matches patterns like:
* - {% extends '@!Bundle/path/to/template.html.twig' %}
* - {% include '@!Bundle/path/to/template.html.twig' %}
* - {% embed '@!Bundle/path/to/template.html.twig' %}
* - {% use '@!Bundle/path/to/template.html.twig' %}
*/
private const ORIGINAL_TEMPLATE_PATTERN = '/@!([A-Za-z0-9_]+)\/([A-Za-z0-9_\/\.\-]+\.twig)/';
public function __construct(
private Environment $twig,
private KernelInterface $kernel
) {
}
public function warmUp(string $cacheDir, ?string $buildDir = null): array
{
$originalTemplates = $this->findOriginalTemplateReferences();
foreach ($originalTemplates as $template) {
try {
$this->twig->load($template);
} catch (Error $e) {
// Ignore compilation errors - template might not exist or have syntax errors
// This is consistent with how TemplateCacheWarmer handles errors
}
}
return [];
}
public function isOptional(): bool
{
return true;
}
/**
* Scans all template files in the templates/ directory and extracts @! references.
*
* @return array<string> Array of unique template names with @! prefix
*/
private function findOriginalTemplateReferences(): array
{
$originalTemplates = [];
$templatesDir = $this->kernel->getProjectDir() . '/templates';
if (!is_dir($templatesDir)) {
return [];
}
$finder = Finder::create()
->files()
->in($templatesDir)
->name('*.twig')
->ignoreUnreadableDirs();
foreach ($finder as $file) {
$content = $file->getContents();
// Find all @! template references in this file
if (preg_match_all(self::ORIGINAL_TEMPLATE_PATTERN, $content, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
// Reconstruct the full template name: @!Bundle/path/to/template.twig
$templateName = '@!' . $match[1] . '/' . $match[2];
$originalTemplates[$templateName] = true;
}
}
}
return array_keys($originalTemplates);
}
}
I would appreciate your feedback on what you consider the optimal solution and am happy to contribute a fix.
Additional Context
No response