À la SymfonyOnline de janvier 2025, Fabien Potencier posait la question sans détour : Twig, encore pertinent en 2025 ? La réponse n'est pas nostalgique — elle est technique. Twig 3.x, co-maintenu avec Symfony 7.x et documenté sur twig.symfony.com, n'est pas un moteur figé dans les années 2010 : il a absorbé les apports de PHP 8, durci son typage, supprimé les patterns douteux hérités de Twig 1, et s'est intégré au nouveau modèle de composants de Symfony UX. Si ton projet Symfony tourne encore sur des templates écrits comme en 2018 — {% filter %}, variables de boucle qui fuient dans le scope parent, extensions sans types — cet article est pour toi.
Ce que Twig 3 a vraiment supprimé — et pourquoi ça compte
La migration de Twig 2 vers Twig 3 ne casse pas grand-chose en surface — la majorité du code compile toujours. Mais Twig 3 expurge tout ce qui était deprecated depuis Twig 2.x, et c'est là que les projets se retrouvent bloqués sans le savoir. Les balises {% filter %} et {% spaceless %} disparaissent. Les alias sameas et divisibleby écrits sans espace ne sont plus reconnus. Les classes Twig_Extension non-namespacées héritées de Twig 1 n'existent plus. En mode strict_variables, toute variable inexistante remonte une erreur au lieu de rendre silencieusement une chaîne vide. C'est inconfortable à migrer — et c'est exactement le but.
- {% filter upper %}...{% endfilter %} → remplacé par {% apply upper %}...{% endapply %}
- {% spaceless %}...{% endspaceless %} → {% apply spaceless %}...{% endapply %}
- Alias sameas / divisibleby sans espace → same as / divisible by
- Classes Twig_* non-namespacées (héritage Twig 1) supprimées
- _self comme argument d'include pour auto-inclusion retiré
- Accès aux variables parent dans un {% for %} sans with : comportement durci
apply : chaîner les filtres sur un bloc entier
La balise apply est la vraie nouveauté ergonomique de Twig 3 pour les templates lourds. Elle permet d'appliquer un ou plusieurs filtres à un bloc de contenu entier, sans passer par une variable intermédiaire. C'est particulièrement utile pour le formatage de zones HTML, l'application de transformations markdown_to_html sur des blocs étendus, ou pour combiner plusieurs filtres d'assainissement avant de marquer le contenu comme raw.
{# Avant — Twig 2, supprimé en Twig 3 #}
{% filter upper|trim %}
Contenu à transformer
{% endfilter %}
{# Twig 3 — syntaxe apply #}
{% apply upper|trim %}
Contenu à transformer
{% endapply %}
{# Cas concret : rendre un bloc markdown en HTML #}
{% apply markdown_to_html %}
# Titre de la section
Paragraphe avec **gras** et _italique_.
Lien vers [la documentation](https://twig.symfony.com/doc/3.x).
{% endapply %}
{# Combiner avec un filtre custom d'assainissement #}
{% apply sanitize_html|raw %}
<p>Contenu utilisateur non filtré</p>
{% endapply %}Le chaînage fonctionne avec n'importe quel filtre enregistré, y compris tes filtres métier. Un pattern particulièrement utile : combiner apply avec un filtre d'assainissement custom puis raw pour les zones où tu construis du HTML dynamique. L'intention est explicite dans le template, pas enfouie dans un helper PHP anonyme.
with : isoler le scope sans polluer le contexte parent
La balise with crée un scope fermé. Tout ce qui est déclaré à l'intérieur — via {% set %} ou via des variables passées en paramètre — reste invisible hors du bloc. C'est la réponse de Twig 3 à un problème classique : les variables de boucle ou de macro qui fuient dans le template parent et provoquent des bugs de rendu difficiles à tracer en production.
{# Scope isolé : foo n'est pas accessible hors du bloc #}
{% with %}
{% set foo = 'valeur locale' %}
<p>{{ foo }}</p>
{% endwith %}
{# {{ foo }} ici → RuntimeError en mode strict_variables #}
{# Variables injectées directement dans le scope #}
{% with { label: 'Enregistré', icon: 'check' } %}
<span class="badge badge--{{ icon }}">{{ label }}</span>
{% endwith %}
{# Combiné avec include pour un contexte propre et explicite #}
{% with { product: featured_product, highlight: true } %}
{% include 'partials/_product_card.html.twig' only %}
{% endwith %}Le mot-clé only sur l'include est complémentaire : il empêche le template inclus d'accéder aux variables du scope appelant. Ensemble, with + only constituent le pattern le plus propre pour les composants Twig sans état dans un projet Symfony 7.2. Chaque include devient un appel à interface explicite, pas un héritage implicite de contexte global.
Extensions typées PHP 8 : sortir de l'ère des closures anonymes
Les extensions Twig custom sont souvent le coin le plus archaïque d'un projet Symfony : closures anonymes sans type hint, méthodes sans retour déclaré, extensions monolithiques qui enregistrent vingt filtres. PHP 8.1 a introduit la syntaxe first-class callable ($this->maMethode(...)), qui s'intègre naturellement avec Twig 3 — tu passes une référence de méthode typée, et l'IDE comprend les signatures en entrée comme en sortie. PHP 8.4, stable depuis novembre 2024, renforce encore la vérification des types au runtime.
<?php
// src/Twig/Extension/PriceExtension.php
namespace App\Twig\Extension;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
final class PriceExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('price', $this->formatPrice(...)),
new TwigFilter('price_vat', $this->formatPriceWithVat(...)),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('currency_symbol', $this->currencySymbol(...)),
];
}
private function formatPrice(float $amount, string $currency = 'EUR'): string
{
return number_format($amount, 2, ',', '\u{202F}') . '\u{00A0}' . $currency;
}
private function formatPriceWithVat(float $amount, float $vatRate = 20.0): string
{
return $this->formatPrice($amount * (1 + $vatRate / 100));
}
private function currencySymbol(string $currency): string
{
return match ($currency) {
'EUR' => '€',
'USD' => '