Skip to content

[TwigBundle] Cache warmer doesn't compile original templates referenced with @! prefix in overrides #62730

@mbessolov

Description

@mbessolov

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:

  1. 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 %}
  1. Warm up the cache:
bin/console cache:warmup --env=prod
  1. Make the Twig cache read-only (common in containerized/production environments):
chmod -R a-w var/cache/prod/twig
  1. Access a page that renders the overridden template

Expected Behavior

The cache warmer should:

  1. Detect that the override template references the original template with @!
  2. Compile both the override AND the original template
  3. 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:

  1. Parse template contents to find dependencies
  2. Follow @! prefix references to original templates
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions