Migrating Twig 2 to Twig 3: A Practical Guide for Symfony
AI generated
SF
{ }
Symfony · Twig 3 · Templates · Migration
Migrating Twig 2 to Twig 3
a practical guide for Symfony templates

Twig 3 splits the CoreExtension into smaller building blocks, changes escaping behavior for custom filters, and removes functions long marked deprecated. Teams that know these breaking changes before the Composer update and deliberately search for deprecations can migrate Symfony templates without unpleasant surprises in production.

18 min read CoreExtension · Escaping · Deprecations Twig 3.x · Symfony 7.x

1. Why the migration from Twig 2 to 3 is due

Twig 3 has been the only actively maintained major version for some years now, and Twig 2 no longer receives security updates. For Symfony projects that include templates exclusively through the Twig bridge, the migration is usually less risky than for Doctrine or other deeply integrated components, because Twig as a template engine has a clearly bounded responsibility. Still, there are enough breaking changes that a blind Composer update in larger projects causes errors in individual templates that only surface when actually rendered.

The second reason for migrating is the quality of error messages and performance: Twig 3 reworked the internal compiler architecture, leading to more precise error messages for syntax errors in templates and, in many cases, noticeably faster compilation of large template trees. For teams with hundreds of Twig templates in a Symfony project, this shows up directly as shorter cache warmup times.

This article shows which breaking changes come up most often when migrating from Twig 2 to 3 in Symfony projects, how to find deprecations up front, and how to adapt custom Twig extensions to the new CoreExtension, now split into smaller building blocks.

2. Breaking changes at a glance

The most important structural change when migrating from Twig 2 to 3 concerns the internal CoreExtension, which in Twig 2 was still a single monolithic class holding all standard filters and functions. In Twig 3 this class was split into several smaller extensions, such as EscaperExtension, StringLoaderExtension, and SandboxExtension, which can be registered independently. For most Symfony projects using Twig exclusively through the standard bridge, this split is transparent, but it directly affects any code that previously checked explicitly against Twig\Extension\CoreExtension or manually instantiated that class.

A second central break concerns removed filters and tags that had been deprecated since Twig 1.x, such as the old {% spaceless %} tag, which was removed in favor of the spaceless filter, along with a few rarely used escaping strategies whose names changed.


<?php
declare(strict_types=1);

// Twig 2.x: checking against the monolithic CoreExtension
use Twig\Extension\CoreExtension;

final class LegacyTwigInspector
{
    public function hasCoreExtension(\Twig\Environment $twig): bool
    {
        return $twig->hasExtension(CoreExtension::class);
    }
}

// Twig 3.x: functionality is split across focused extensions
use Twig\Extension\EscaperExtension;
use Twig\Extension\StringLoaderExtension;

final class ModernTwigInspector
{
    public function hasEscaper(\Twig\Environment $twig): bool
    {
        return $twig->hasExtension(EscaperExtension::class);
    }

    public function hasStringLoader(\Twig\Environment $twig): bool
    {
        return $twig->hasExtension(StringLoaderExtension::class);
    }
}

For the vast majority of Symfony templates themselves, meaning the .twig files with filters like |upper or functions like path(), the CoreExtension split changes nothing, because Symfony automatically registers the new extensions through the Twig bridge. Almost only PHP classes that use Twig directly without Symfony's integration are affected.

3. Namespace changes and minimum PHP requirements

Twig 3 raised the minimum requirement to PHP 7.2 in early 3.x releases and to PHP 8.1 in current versions, which is usually already satisfied in combination with a Symfony upgrade. More relevant for the actual migration are namespace shifts within Twig itself: classes like Twig_Environment from the old, un-namespaced Twig 1 convention were finally removed, after existing only as aliases for Twig\Environment in Twig 2.

Projects with a very old Twig 1 history that never fully completed this transition need to make sure, before jumping to Twig 3, that no references to the old Twig_* class names remain. A project-wide search for Twig_ as a prefix in PHP files is the fastest way to find remaining legacy code before the actual Composer update begins.


<?php
declare(strict_types=1);

// Removed since Twig 2, definitively gone in Twig 3: old unnamespaced classes
// class CustomTwigExtension extends Twig_Extension { }

// Correct: fully namespaced since Twig 2, mandatory in Twig 3
namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

final class CustomTwigExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            new TwigFilter('shout', $this->shout(...)),
        ];
    }

    private function shout(string $value): string
    {
        return strtoupper($value) . '!';
    }
}

Teams that already worked cleanly with the namespaced classes in Twig 2 usually don't need to change anything here. The effort concentrates on projects that grew over many years and never fully updated a handful of legacy classes.

4. CoreExtension split and custom extensions

Custom Twig extensions that register new filters or functions are usually not directly affected by the CoreExtension split, because the public AbstractExtension API remained unchanged. Affected are extensions that internally accessed specific methods of the old monolithic CoreExtension, for example to extend an existing escaping strategy instead of registering a new custom filter function.

For Symfony projects with many custom Twig extensions, it's worth deliberately splitting them by area of responsibility, similar to the split Twig performed internally. Instead of a single large AppTwigExtension class with twenty filters for different purposes, splitting into focused extensions per domain is more maintainable and keeps tests smaller and more targeted.


<?php
declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;

// Focused extension for currency formatting, registered as a Symfony service
final class CurrencyExtension extends AbstractExtension
{
    public function __construct(
        private readonly string $defaultCurrency = 'EUR',
    ) {
    }

    public function getFilters(): array
    {
        return [
            new TwigFilter('money', $this->formatMoney(...)),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('default_currency', fn (): string => $this->defaultCurrency),
        ];
    }

    private function formatMoney(int $amountCents, ?string $currency = null): string
    {
        $amount = $amountCents / 100;

        return number_format($amount, 2) . ' ' . ($currency ?? $this->defaultCurrency);
    }
}

Since Symfony automatically adds Twig extensions to the Twig environment via the twig.extension tag, registration itself does not change because of the CoreExtension split. The only area that needs attention is extensions that actually try to override core Twig behavior, instead of additively contributing new filters and functions.

5. Whitespace control and changed escaping

Twig 3 tightens the behavior of the automatic escaping strategy for custom filters that produce output marked as safe. In Twig 2, there were a few edge cases where a filter with is_safe, combined with nested function calls, was escaped inconsistently. Twig 3 fixes these inconsistencies, which in rare cases means HTML that was previously output unescaped now gets correctly escaped, if a filter had been incorrectly marked as safe.

For templates that relied on this previously buggy behavior, for example to output HTML from a custom filter unchecked, a visual review of the affected pages is necessary after migrating. The correct way to deliberately mark HTML output remains |raw or an explicit is_safe declaration in the filter definition, never an accidental interplay of several filters whose behavior can differ with every Twig version.


<?php
declare(strict_types=1);

namespace App\Twig;

use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;

final class MarkdownExtension extends AbstractExtension
{
    public function getFilters(): array
    {
        return [
            // Explicit is_safe declaration: Twig 3 respects this consistently,
            // no accidental escaping bypass through filter chaining
            new TwigFilter(
                'markdown_to_html',
                $this->renderMarkdown(...),
                ['is_safe' => ['html']],
            ),
        ];
    }

    private function renderMarkdown(string $markdown): string
    {
        // Simplified: a real implementation delegates to a Markdown parser
        return '<p>' . htmlspecialchars($markdown) . '</p>';
    }
}

A sensible test after migrating: open every template that uses custom filters with HTML output in a browser and check whether HTML tags render correctly or show up escaped as text. Automated snapshot tests of the rendered HTML output catch regressions here more reliably than pure unit tests of the Twig extension classes.

6. Finding deprecations before migrating

Twig logs deprecations through the same mechanism as PHP itself, combined with the symfony/twig-bridge deprecation handler, which can be enabled in the Symfony test suite. Before the actual upgrade to Twig 3, it pays to run a test pass with deprecation collection enabled on the current Twig 2 version, because many of the features later removed were already marked deprecated and produced warnings during regular operation that usually get ignored in production logs.

A targeted search across templates for {% spaceless %}, outdated date filter options, and direct access to internal Twig classes covers most of the places that need adjusting before upgrading. For very large template trees with several hundred files, a simple grep command is often more efficient than waiting for runtime errors in individual, rarely called templates.


# Find deprecated {% spaceless %} tag usage across all templates
grep -rn "{% spaceless %}" templates/

# Find any remaining references to old unnamespaced Twig 1 classes
grep -rn "Twig_" src/ templates/

# Run the test suite with Symfony's deprecation helper enabled
SYMFONY_DEPRECATIONS_HELPER=max[self]=0 bin/phpunit

These three commands together cover the most common migration risks: outdated tag syntax in templates, remaining old class names in PHP code, and general deprecations reported by symfony/twig-bridge. Running this search before the actual Composer update significantly reduces the number of surprises during the later test run.

7. Rector and twig-cs-fixer for automated migration

For PHP-side code that defines Twig extensions, general PHP modernization Rector rules help convert outdated constructor patterns to modern constructor property promotion, while the actual Twig-specific migration usually stays manual, because Rector doesn't ship specific rules for Twig extension APIs. twig-cs-fixer, on the other hand, checks template syntax itself for consistency and can be integrated into the CI pipeline as a linting step, preventing new templates from introducing outdated patterns while the migration is still in progress.


<?php
declare(strict_types=1);

// twig-cs-fixer.php: lints all templates for consistent, modern syntax
use TwigCsFixer\Config\Config;
use TwigCsFixer\Ruleset\Ruleset;

$ruleset = new Ruleset();
$ruleset->addStandard(new \TwigCsFixer\Standard\Twig());

$config = new Config();
$config->setRuleset($ruleset);
$config->setFinder(
    (new \TwigCsFixer\Finder\TemplateFinder())->in(__DIR__ . '/templates')
);

return $config;

Combining twig-cs-fixer in the CI pipeline with a one-time manual pass through the grep searches from the previous section covers the vast majority of migration tasks in practice, without a team needing to write its own migration script.

8. Migration strategy across several minor versions

The safest strategy for migrating from Twig 2 to 3 does not go directly from the oldest Twig 2 version to the newest Twig 3 version, but first to the latest Twig 2 minor version, which already emits deprecation warnings for every feature removed in Twig 3. Only once these warnings are fully resolved does the actual jump to Twig 3 follow, which drastically reduces the risk of unexpected runtime errors, because each change can be tested in isolation instead of accounting for multiple migration steps at once.

In practice this means: first run composer require twig/twig:^2.15 as the final Twig 2 version, run the test suite with deprecation collection enabled, fix every reported deprecation, and only then run composer require twig/twig:^3.0. This intermediate step costs extra time but prevents having to debug several categories of breaking changes at once.

9. Twig 2 vs. 3 side by side

The table below summarizes the key differences between Twig 2 and Twig 3 for Symfony projects.

Area Twig 2.x Twig 3.x Migration effort
CoreExtension A single monolithic class Several focused extensions Low for standard templates
{% spaceless %} tag Available Removed, filter replacement needed Low, project-wide search
Escaping with is_safe Inconsistent in edge cases Consistent and predictable Medium, visual review needed
Compilation performance Baseline Measurably faster No effort, direct gain

For most Symfony projects, the benefits clearly outweigh the effort: faster compilation, more consistent escaping, and more precise error messages for template syntax errors, at manageable migration effort, as long as no deep hooks into internal Twig classes exist.

Mironsoft

Symfony template migrations and Twig extension development

Ready to migrate your Twig templates to Twig 3?

We audit your template base for deprecations, adapt custom Twig extensions to the new CoreExtension structure, and support the rollout with visual regression tests for affected pages.

Deprecation search

Full analysis of templates and Twig extension classes

Extension adjustment

Custom filters and functions adapted to the split CoreExtension

Visual regression tests

Systematic comparison of escaping behavior before and after migration

10. Summary

Migrating from Twig 2 to 3 is less risky for most Symfony projects than other major upgrades, because Twig as a template engine has a clearly bounded scope. The split of the monolithic CoreExtension into focused building blocks mainly affects PHP code using Twig directly, while standard templates through the Symfony bridge usually keep working unchanged. Removed tags like {% spaceless %} and more consistent escaping behavior are the most visible changes for the template base itself.

The safest migration path runs through the latest Twig 2 minor version with full deprecation collection, followed by fixing every reported warning, and only then the actual jump to Twig 3. Grep searches for outdated tags and class names, combined with twig-cs-fixer in the CI pipeline, cover most migration tasks without a team needing to write a custom migration script.

Migrating Twig 2 to Twig 3 — The Essentials at a Glance

CoreExtension split

Several focused extensions instead of one class, mainly affects custom PHP integrations.

{% spaceless %} removed

A project-wide grep search before upgrading reliably finds affected templates.

More consistent escaping

A visual review of templates with custom is_safe filters is recommended after migrating.

Two-step upgrade

Clear deprecations on the last Twig 2 version first, then switch to Twig 3.

11. FAQ: Migrating Twig 2 to Twig 3

1Do all templates need adjusting?
No, the majority works unchanged. Only spaceless tags and broken is_safe filters need adjustment.
2What does the CoreExtension split change?
Several focused extensions instead of one class, mainly affects direct PHP Twig usage without Symfony.
3How do I find spaceless usages?
grep -rn '{% spaceless %}' templates/ reliably finds all places.
4Must custom extensions be rewritten?
Usually not, the AbstractExtension API remained unchanged.
5Why does HTML suddenly get escaped?
Twig 3 fixes escaping inconsistencies for is_safe filters, filter declaration needs correcting.
6Jump directly to Twig 3?
No, clear deprecations on the last Twig 2 version first, then move to Twig 3.
7Does Rector automate the migration?
Only general PHP modernization, no specific Twig API rules, migration stays largely manual.
8What does twig-cs-fixer do?
Checks template syntax, prevents new outdated patterns during migration as a linting step.
9Which PHP version does Twig 3 need?
Current versions require PHP 8.1, usually already satisfied by a Symfony upgrade.
10Does Twig 3 improve performance?
Yes, reworked compiler architecture measurably speeds up compilation of large template trees.