Twig — syntax, performance, security, components
TL;DR — Twig compile chaque template en classe PHP (
var/cache/.../twig/) → runtime ultra-rapide. Auto-escaping HTML par défaut = ton meilleur ami contre XSS, ne le désactive jamais sans|rawexplicite.{% extends %}+{% block %}= inheritance,{% include %}= composition simple,{% embed %}= include + override blocks. Twig Components (Symfony UX) apportent une vraie réutilisabilité OO façon React/Vue.
🧠 Mental model — ASCII diagram + analogie
src/Controller/Foo.php
│ return $this->render('foo.html.twig', ['x' => 42]);
▼
Twig\Environment::render('foo.html.twig', ['x' => 42])
│
▼
┌──────────────────────────────────────────────────────┐
│ Loader (FilesystemLoader) │
│ - search paths: templates/, vendor/*/Resources/views│
│ - resolves "foo.html.twig" → /abs/path │
└──────────────────────────────────────────────────────┘
│
▼
Cache check: var/cache/dev/twig/{hash}.php exists?
│
├─ YES → require + invoke render() (fast path)
│
└─ NO → Lexer → Parser → Compiler → PHP class
│
▼
Write to cache, then require
│
▼
Template_xxxxx::doDisplay($context, $blocks)
│ echo HTML to output
▼
Captured by ob_*, returned as string → ResponseAnalogie : Twig est un transpiler. Tu écris en Twig DSL (lisible), il génère du PHP optimisé (illisible mais rapide). Comme TypeScript → JavaScript. Tu ne lis jamais le PHP généré, mais il existe et c'est ce qui s'exécute en prod.
🛠️ Code minimal — extends, blocks, macros, components
{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
<title>{% block title %}My App{% endblock %}</title>
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
{% endblock %}
</head>
<body>
<header>{% include 'partials/_header.html.twig' %}</header>
<main>
{% for label, messages in app.flashes %}
{% for msg in messages %}
<div class="alert alert-{{ label }}">{{ msg }}</div>
{% endfor %}
{% endfor %}
{% block body %}{% endblock %}
</main>
{% block javascripts %}
<script type="module" src="{{ asset('build/app.js') }}"></script>
{% endblock %}
</body>
</html>{# templates/article/show.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ article.title }} — {{ parent() }}{% endblock %}
{% block body %}
<article>
<h1>{{ article.title }}</h1>
<p class="meta">
{{ 'article.published_at'|trans({ date: article.publishedAt|format_datetime('long', 'short') }) }}
</p>
<div class="content">{{ article.content|raw }}</div>
{% if article.tags is not empty %}
<ul class="tags">
{% for tag in article.tags %}
<li>{{ _self.tag_pill(tag) }}</li>
{% endfor %}
</ul>
{% endif %}
</article>
{{ render(controller('App\\Controller\\CommentController::list', { article: article.id })) }}
{% endblock %}
{# Macro inline #}
{% macro tag_pill(tag) %}
<span class="pill pill-{{ tag.color }}">{{ tag.name }}</span>
{% endmacro %}{# templates/email/welcome.html.twig — embed avec override #}
{% embed '@email/layout.html.twig' with { brand: 'Acme' } %}
{% block subject %}Welcome to Acme!{% endblock %}
{% block content %}
<h1>Hello {{ user.name }}!</h1>
<p>Thanks for signing up.</p>
{% endblock %}
{% endembed %}Twig Component (Symfony UX) :
// src/Twig/Components/Alert.php
<?php
namespace App\Twig\Components;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Alert
{
public string $type = 'info';
public string $message = '';
public bool $dismissible = false;
}{# templates/components/Alert.html.twig #}
<div class="alert alert-{{ type }}{% if dismissible %} alert-dismissible{% endif %}" role="alert">
{{ message }}
{% if dismissible %}
<button type="button" class="btn-close" data-dismiss="alert">×</button>
{% endif %}
</div>{# Usage #}
<twig:Alert type="success" message="Saved!" dismissible />Extension Twig custom :
// src/Twig/MoneyExtension.php
<?php
namespace App\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
final class MoneyExtension extends AbstractExtension
{
public function getFilters(): array
{
return [
new TwigFilter('money', $this->formatMoney(...), is_safe: ['html']),
];
}
public function formatMoney(int $cents, string $currency = 'EUR', string $locale = 'fr_FR'): string
{
$fmt = new \NumberFormatter($locale, \NumberFormatter::CURRENCY);
return $fmt->formatCurrency($cents / 100, $currency);
}
}🎯 Patterns courants
Layout inheritance hiérarchique —
base.html.twig→layouts/admin.html.twig(extends base) →admin/dashboard.html.twig(extends admin). 3 niveaux max raisonnable.render(controller(...))pour ESI — pour fragments lourds avec leur propre cache lifetime (sidebar, header user-specific). Twig génère une sous-requête → propre cache HTTP.Twig Components — équivalent React/Vue côté serveur. Reusable, typed, testable. Live Components (UX) ajoutent réactivité sans écrire de JS.
is_safe: ['html']— pour un filter qui retourne du HTML déjà escaped. Sinon Twig double-escape. À utiliser avec extrême prudence (XSS).Globals via
framework.twig.globals— injecterapp_version,feature_flags, etc. accessibles partout. Lazy via'@service'syntaxe.{% trans %}avec ICU MessageFormat — pluralisation propre via le domaine ICU.twig{{ 'cart.items'|trans({ '%count%': items|length }, 'cart') }}Avec un catalogue
translations/cart+intl-icu.fr.yamlqui contient la règle ICU :yaml# translations/cart+intl-icu.fr.yaml cart.items: >- {count, plural, =0 {Votre panier est vide} one {# article} other {# articles} }Le suffixe
+intl-icusur le nom de fichier active leMessageFormatterICU (pluriels, genre, ordinaux, dates inline) au lieu du format legacy%count%/|.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 : Twig 3.x stable.
{% apply %}block-level filters,name|map(v => v.foo)arrow functions disponibles. - 6.0 : Symfony UX Twig Components arrive (v1.0). Removal de Twig 2 support.
- 6.1 : fonction
ux_icon('mdi:home')(avec le bundle ux-icons), première version Live Components. - 6.2 : amélioration cache compilation,
--no-debugwarmup beaucoup plus rapide. - 6.3 :
format_date_timenatif via Intl,{% with %}rebind context. - 6.4 LTS :
twig:lintplus strict, Twig Components stables avec attribute injection (#[ExposeInTemplate],#[PreMount],#[PostMount]). - 7.0 : suppression
Twig\Loader\ArrayLoadercache (use FilesystemLoader). Removal de plusieurs deprecated nodes. - 7.1+ :
app.enabled_locales, support natif des Twig Components nested slots.
⚠️ Pitfalls
|rawpartout — désactive auto-escape → XSS. À utiliser uniquement sur du contenu dont tu contrôles la source (Markdown rendered, etc.). Sinon utiliser|escape('js'),|escape('css'), etc.- Variables non-définies — par défaut Twig retourne
nullsilencieusement. Activerstrict_variables: trueen dev pour throw → catch les typos. includevsembed—includen'override pas les blocks de l'inclu,embedoui. Confondre = blocks vides.- Recompilation à chaque requête —
auto_reload: true(dev) vérifie mtime à chaque render → lent. En prod,auto_reload: false, on warmup le cache au déploiement. render(controller(...))qui re-fait tout le pipeline — sub-request = nouveau Firewall, nouveau Doctrine query, etc. Cache-able via HTTP cache ESI, mais coûteux sans cache.- Twig + objects mutables — un
{% set %}ne fait pas une vraie copie. Modifier aprèssetaffecte aussi l'original. |transsans fallback locale — clé manquante = retourne la clé telle quelle. Configurerframework.translator.fallbacks: [en]+translation:extractpour audit.- Twig Component sans
#[PreMount]— si tu veux normaliser/valider les props avant render, oublier#[PreMount]= passage de données brutes.
🔐 Sécurité — l'auto-escaping en profondeur (le sujet qui fait couler les pentests)
Le modèle mental qui sépare un junior d'un senior : l'auto-escaping de Twig est contextuel par défaut au contexte HTML, et seulement HTML. Une interpolation Twig applique htmlspecialchars($x, ENT_QUOTES | ENT_SUBSTITUTE). C'est correct pour du texte entre des balises ou dans un attribut entre guillemets. Ce n'est pas suffisant ailleurs. (Dans le tableau ci-dessous, [x] représente une interpolation Twig — délimiteurs double-accolade — pour rester lisible hors bloc de code.)
| Contexte d'injection | Échappement HTML suffit ? | Stratégie correcte |
|---|---|---|
<p>[x]</p> | ✅ Oui | défaut |
<a title="[x]"> | ✅ Oui (ENT_QUOTES couvre " et ') | défaut |
<a href="[url]"> | ❌ Non — javascript: passe | valider le schéma (http/https/mailto), jamais d'URL user brute |
<script>var d = [data]</script> | ❌ Non — casse le JS / breakout </script> | [data|json_encode] ou mieux : passer via data-* attribute + lire en JS |
<div onclick="[x]"> | ❌ Non | bannir les handlers inline (CSP), utiliser addEventListener |
<style>.x { color: [c] }</style> | ❌ Non | [c|escape('css')] + allowlist |
Attribut non quoté <div class=[x]> | ❌ Non — espace = nouvel attribut | toujours quoter les attributs |
{# Le piège classique : injecter du contexte serveur dans du JS #}
<script>
// ❌ DANGEREUX — un nom = "</script><script>alert(1)" casse tout
const user = "{{ user.name }}";
// ✅ json_encode produit un littéral JS sûr ET échappe </script>, U+2028/U+2029
const user = {{ user.name|json_encode|raw }};
</script>
{# ✅ Encore mieux : pas de JS inline du tout, on lit le DOM #}
<div id="app" data-user="{{ user.name }}"></div>Pourquoi |json_encode|raw et pas |json_encode seul ? Parce que sans |raw, Twig ré-échapperait les guillemets du JSON en " → JSON cassé. Le json_encode de Twig est configuré avec JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP, donc </script> est neutralisé : pas de breakout possible. C'est la SEULE combinaison |json_encode|raw qui est légitime.
is_safe: ['html'] — le couteau qui coupe les deux sens. Quand tu déclares un filtre is_safe: ['html'], tu promets à Twig que la sortie est déjà sûre, donc il n'échappera pas. Si ton filtre interpole une variable user sans l'échapper lui-même → XSS stored. Règle staff : un filtre is_safe: ['html'] doit échapper tout ce qui vient de l'extérieur via twig_escape_filter() ou htmlspecialchars, et ne marquer safe que le HTML structurel qu'il génère.
// Filtre qui rend du HTML : on échappe nous-mêmes les parties dynamiques
public function badge(string $label, string $color): string
{
$label = htmlspecialchars($label, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$color = preg_match('/^[a-z]+$/', $color) ? $color : 'gray'; // allowlist
return sprintf('<span class="badge badge-%s">%s</span>', $color, $label);
}
// new TwigFilter('badge', $this->badge(...), is_safe: ['html'])La Sandbox (pour les templates que des utilisateurs écrivent — CMS, email builder) : SandboxExtension restreint tags/filtres/fonctions/méthodes autorisés. Sans elle, un template user peut appeler une méthode mutante (user.delete()) ou constant('...') dans une interpolation → RCE/leak. Ne JAMAIS rendre du Twig fourni par l'utilisateur sans sandbox + SecurityPolicy allowlist explicite.
CSP > échappement. Le filet de sécurité ultime : une Content-Security-Policy stricte (script-src 'self' 'nonce-...', pas de 'unsafe-inline') neutralise la quasi-totalité des XSS même si un échappement est raté. Twig + nonce (via csp_nonce() du NelmioSecurityBundle) sur chaque <script>. L'échappement protège contre l'injection, la CSP protège contre l'exécution — défense en profondeur.
⚡ Performance & observabilité — raisonner en staff
Le coût n'est pas où on le croit. En prod, le rendu Twig lui-même (le doDisplay() PHP compilé) est rarement le bottleneck — c'est de l'echo de string. Les vrais coûts :
| Source de lenteur | Symptôme | Remède |
|---|---|---|
| Compilation à chaud | Premier hit après deploy lent, spikes | cache:warmup au build (image immutable), jamais de compile en prod runtime |
auto_reload: true en prod | stat() syscall par template par requête | auto_reload: false (auto si APP_DEBUG=0) |
| N+1 Doctrine dans le template | DB time explose proportionnel aux lignes | Eager-load / DTO en amont — un template ne doit pas déclencher de requête |
render(controller()) non caché | Sub-request = full kernel boot (firewall, listeners) | render_esi() + reverse proxy, ou cache.app autour du fragment |
Boucles {% for %} massives + filtres lourds | CPU, mémoire | Paginer ; pré-formater côté PHP ; {% cache %} (twig/cache-extra) |
Le cache de fragment Twig (symfony/twig-pack inclut twig/cache-extra) — sous-utilisé et puissant :
{# Met en cache ce bloc 1h, clé = id + version → invalidation par bump de version #}
{% cache "sidebar_" ~ category.id ttl(3600) %}
{# rendu coûteux : arbre de catégories, compteurs... #}
{{ render_category_tree(category) }}
{% endcache %}Observabilité — ce qu'un senior instrumente :
- Web Profiler → onglet Twig (dev) : nombre de templates rendus, temps de rendu cumulé, et surtout le call graph des includes/embeds. Un nombre de templates qui explose (centaines par page) = sur-fragmentation.
stopwatch/Symfony Profiler en staging : repérer un template qui mange 200 ms = quasi toujours un N+1 caché ou un filtre custom non mémoïsé.- Métrique prod :
twig.rendering.durationvia unEventSubscriberou OpenTelemetry auto-instrumentation. Alerter si le p99 du rendu dépasse un seuil. lint:twigen CI :php bin/console lint:twig templates/ --show-deprecationscasse le build sur une erreur de syntaxe ou un usage déprécié — bien avant la prod.
// Mesurer le temps de rendu de chaque template via un Profiler listener custom (extrait)
// $this->stopwatch->start($templateName, 'twig'); ... ->stop($templateName);
// En prod, remplacer Stopwatch par un compteur Prometheus / span OTel.Règle d'or staff : le template est la dernière étape, pas un endroit où chercher des données. Toute la logique (requêtes, calculs, autorisation) doit être résolue avant render(). Un template qui contient de la logique métier ou déclenche de l'I/O est un code smell qui sabote perf ET testabilité.
🧪 Testing
// tests/Twig/MoneyExtensionTest.php
<?php
namespace App\Tests\Twig;
use App\Twig\MoneyExtension;
use PHPUnit\Framework\TestCase;
final class MoneyExtensionTest extends TestCase
{
public function testFormatMoneyEur(): void
{
$ext = new MoneyExtension();
self::assertSame('12,34 €', $ext->formatMoney(1234, 'EUR', 'fr_FR'));
}
}Tester un template rendu via WebTestCase :
final class ArticleControllerTest extends WebTestCase
{
public function testShowRendersTitle(): void
{
$client = static::createClient();
$client->request('GET', '/fr/articles/42');
self::assertResponseIsSuccessful();
self::assertSelectorTextContains('article h1', 'Mon Article');
self::assertSelectorAttributeContains('html', 'lang', 'fr');
}
}Tester un Twig Component :
use Symfony\UX\TwigComponent\Test\InteractsWithTwigComponents;
final class AlertComponentTest extends KernelTestCase
{
use InteractsWithTwigComponents;
public function testRendersMessage(): void
{
$rendered = $this->renderTwigComponent('Alert', ['type' => 'danger', 'message' => 'Boom']);
self::assertStringContainsString('alert-danger', $rendered);
self::assertStringContainsString('Boom', $rendered);
}
}Debug CLI :
php bin/console debug:twig
php bin/console debug:twig --filter=money
php bin/console lint:twig templates/
php bin/console debug:translation fr --only-missing
php bin/console cache:warmup --env=prod # pre-compile templates🎬 Cas d'usage concrets
Scénario 1 — Cabinet juridique : génération de contrats PDF via Twig + Gotenberg
Contexte : un cabinet d'avocats d'affaires génère ~250 contrats par jour (NDA, contrats de prestation, accords de partenariat). Chaque contrat est un template Twig HTML (avec CSS print-friendly) rendu côté serveur, puis converti en PDF via Gotenberg (microservice Chromium headless). Les variables : parties, montants, clauses optionnelles, juridiction.
Le ContractRenderer utilise $twig->render('contracts/nda.html.twig', $data) pour produire le HTML, puis poste sur Gotenberg qui retourne le PDF. Les templates héritent d'un contracts/_layout.html.twig qui définit en-tête, footer (pagination, mentions légales), styles @page. Des blocks {% block clauses %} permettent à chaque contrat type de surcharger sans dupliquer. Un Twig extension custom legal_format_date (date pleine "5 février 2026"), legal_amount_in_words (montant en lettres "vingt-cinq mille euros"), legal_signature_block pour le bloc signature standardisé.
Bénéfice : un avocat junior crée un nouveau type de contrat en copiant un template existant + override des blocks ; aucune modification du code PHP. Le PDF est rendu en 800 ms moyen, signable directement via Yousign.
Scénario 2 — FinTech (Qonto-like) : factures clientes en Twig + extension MoneyExtension
Contexte : néobanque pro qui émet 80 000 factures mensuelles à ses clients pour leurs abonnements. Les factures doivent être conformes à la facturation électronique française (Factur-X obligatoire en B2B au 2026-09). Format : PDF/A-3 + XML CII embarqué.
Le template invoices/invoice.html.twig est riche : header avec logo client, lignes avec quantités/montants, TVA 20% détaillée, totals en monnaies multiples (EUR, USD pour clients internationaux). Une extension Twig custom MoneyExtension expose un filter money_format (voir l'exemple end-to-end plus bas) qui utilise Money\Money + Money\Currencies\ISOCurrencies pour le bon formatage selon la locale (séparateurs , ou ., symbole € avant/après).
Pour les ID de transaction, un Twig component <twig:TransactionRow> (Symfony UX) factorise la ligne de transaction réutilisée dans factures, relevés, exports. Le PDF est signé électroniquement après rendu, archivé 10 ans selon obligation fiscale.
Scénario 3 — E-commerce (Sézane-like) : emails transactionnels A/B-testés
Contexte : retailer mode FR envoie ~1,5 M emails transactionnels/mois (confirmation commande, expédition, retour, avis). Les templates doivent : (1) supporter dark mode email, (2) être A/B-testés (2 versions du CTA, du visuel hero), (3) être traduits FR/EN/ES/IT/DE, (4) inclure des produits recommandés calculés à chaud.
Architecture : emails/order/confirmation.html.twig étend emails/_base.html.twig (inline CSS, MJML compilé). Un controller EmailRenderer injecte un AbExperimentResolver qui set {% set variant = experiments.variant('order_cta_v2') %} dans le contexte. Le template fait {% if variant == 'a' %}...{% else %}...{% endif %}. Les produits recommandés sont rendus via un fragment contrôleur — sub-request avec cache HTTP Cache-Control: private, max-age=3600 (esi-like) :
{{ render(controller(
'App\\Controller\\RecommendationFragmentController',
{ customerId: customer.id, context: 'post_order' }
)) }}Les traductions sont dans translations/emails.{locale}.yaml, accédées par :
{{ 'order.confirmation.title' | trans }}🛠️ Exemple end-to-end
Use case : génération d'une facture FinTech en Twig avec extension MoneyExtension custom, layout réutilisable, et rendu via service injectable.
// src/Billing/Twig/MoneyExtension.php
<?php
declare(strict_types=1);
namespace App\Billing\Twig;
use Money\Currencies\ISOCurrencies;
use Money\Formatter\IntlMoneyFormatter;
use Money\Money;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
final class MoneyExtension extends AbstractExtension
{
/** @var array<string, IntlMoneyFormatter> */
private array $formatters = [];
public function getFilters(): array
{
return [
new TwigFilter('money', $this->formatMoney(...)),
new TwigFilter('money_vat', $this->formatVat(...)),
];
}
public function getFunctions(): array
{
return [
new TwigFunction('money_subtract', $this->subtract(...)),
];
}
public function formatMoney(Money $money, string $locale = 'fr_FR'): string
{
$formatter = $this->formatters[$locale] ??= new IntlMoneyFormatter(
new \NumberFormatter($locale, \NumberFormatter::CURRENCY),
new ISOCurrencies(),
);
return $formatter->format($money);
}
public function formatVat(Money $money, int $ratePercent, string $locale = 'fr_FR'): string
{
$vat = $money->multiply((string) ($ratePercent / 100));
return $this->formatMoney($vat, $locale);
}
public function subtract(Money $a, Money $b): Money
{
return $a->subtract($b);
}
}// src/Billing/Dto/Invoice.php
<?php
declare(strict_types=1);
namespace App\Billing\Dto;
use Money\Money;
final readonly class Invoice
{
/** @param list<InvoiceLine> $lines */
public function __construct(
public string $number,
public \DateTimeImmutable $issuedAt,
public Customer $customer,
public array $lines,
public int $vatRatePercent,
) {}
public function subTotal(): Money
{
$first = $this->lines[0]->total();
return array_reduce(
\array_slice($this->lines, 1),
static fn (Money $acc, InvoiceLine $l): Money => $acc->add($l->total()),
$first,
);
}
public function vatAmount(): Money
{
return $this->subTotal()->multiply((string) ($this->vatRatePercent / 100));
}
public function total(): Money
{
return $this->subTotal()->add($this->vatAmount());
}
}
final readonly class InvoiceLine
{
public function __construct(
public string $label,
public int $quantity,
public Money $unitPrice,
) {}
public function total(): Money
{
return $this->unitPrice->multiply((string) $this->quantity);
}
}
final readonly class Customer
{
public function __construct(
public string $companyName,
public string $siret,
public string $vatNumber,
public string $address,
) {}
}{# templates/billing/invoice.html.twig #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Facture {{ invoice.number }}</title>
<style>
@page { size: A4; margin: 2cm; }
body { font-family: 'Helvetica', sans-serif; font-size: 11pt; color: #222; }
table { width: 100%; border-collapse: collapse; margin: 1.5em 0; }
th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
.total { font-weight: bold; background: #f6f6f6; }
</style>
</head>
<body>
<header>
<h1>Facture {{ invoice.number }}</h1>
<p>Émise le {{ invoice.issuedAt | format_date('long', locale='fr') }}</p>
</header>
<section class="customer">
<strong>{{ invoice.customer.companyName }}</strong><br>
SIRET : {{ invoice.customer.siret }} — TVA : {{ invoice.customer.vatNumber }}<br>
{{ invoice.customer.address | nl2br }}
</section>
<table>
<thead>
<tr><th>Désignation</th><th>Qté</th><th>PU HT</th><th>Total HT</th></tr>
</thead>
<tbody>
{% for line in invoice.lines %}
<tr>
<td>{{ line.label }}</td>
<td>{{ line.quantity }}</td>
<td>{{ line.unitPrice | money }}</td>
<td>{{ line.total | money }}</td>
</tr>
{% endfor %}
<tr><td colspan="3">Sous-total HT</td><td>{{ invoice.subTotal | money }}</td></tr>
<tr><td colspan="3">TVA ({{ invoice.vatRatePercent }}%)</td><td>{{ invoice.vatAmount | money }}</td></tr>
<tr class="total"><td colspan="3">Total TTC</td><td>{{ invoice.total | money }}</td></tr>
</tbody>
</table>
</body>
</html>// src/Billing/Service/InvoicePdfRenderer.php
<?php
declare(strict_types=1);
namespace App\Billing\Service;
use App\Billing\Dto\Invoice;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Twig\Environment;
final readonly class InvoicePdfRenderer
{
public function __construct(
private Environment $twig,
private HttpClientInterface $gotenberg,
) {}
public function render(Invoice $invoice): string
{
$html = $this->twig->render('billing/invoice.html.twig', ['invoice' => $invoice]);
$response = $this->gotenberg->request('POST', '/forms/chromium/convert/html', [
'body' => [
'files' => new \CURLFile('data:text/html;base64,' . base64_encode($html)),
'pdfa' => 'PDF/A-3b',
],
]);
return $response->getContent();
}
}Le flow : un trigger émet InvoiceIssued → message handler instancie Invoice, appelle InvoicePdfRenderer::render(), stocke le PDF sur S3, met à jour la facture en base avec l'URL. L'expert-comptable client reçoit la facture par email, conforme Factur-X (PDF/A-3 + XML CII intégrable en ajoutant l'XML via Gotenberg embedded_files).
🔁 Quand utiliser / éviter
- Twig : toute page HTML, emails, SMS, n'importe quel texte template. Évite pour JSON pur (utilise Serializer).
- Twig Components : éléments UI réutilisables (Alert, Card, Modal). Évite pour la page complète — pas la bonne abstraction.
render(controller(...)): fragments avec logique propre (cache, sécurité). Évite si la donnée est déjà dispo dans le template parent (passe en var, c'est gratuit).- Live Components : forms réactifs, search-as-you-type, sans écrire de JS. Évite si tu as déjà un SPA Vue/React (double pipeline UI).
- Macros vs Components : macros pour helpers simples sans state (formatter), components pour blocs UI complexes avec props typées.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.
1. Extension Twig MoneyExtension testée et mémoïsée — implémente
Objectif : écrire un filtre money qui formate un Money\Money selon la locale, sans recréer un NumberFormatter à chaque appel (coûteux), et le couvrir par un test paramétré (EUR/fr_FR, USD/en_US, JPY/ja_JP — attention aux devises sans décimales).
Indice/Solution : cache des formatters dans private array $formatters indexé par locale.currency (le ??= montré dans l'exemple end-to-end). Test avec #[DataProvider]. Piège JPY : pas de centimes, vérifie que IntlMoneyFormatter lit bien le subunit via ISOCurrencies.
2. Twig Component <twig:DataTable> avec slots et props typées — implémente
Objectif : un composant générique qui prend rows (iterable), un block de colonnes via slot nommé, et un état vide. Props typées, #[ExposeInTemplate] pour un compteur dérivé, #[PreMount] pour normaliser rows (array vs Collection).
Indice/Solution : #[AsTwigComponent] avec public iterable $rows. #[PreMount] public function preMount(array $data): array pour caster en array. Slot {% block columns %} rendu via la fonction block('columns') ou <twig:block name="columns">. Test via InteractsWithTwigComponents.
3. Fragment caché avec {% cache %} + invalidation par version — production-grade
Objectif : rendre une sidebar de navigation coûteuse (arbre de catégories, compteurs DB) et la cacher 1h, avec invalidation immédiate quand une catégorie change (sans attendre le TTL).
Indice/Solution : {% cache "nav_" ~ navVersion ttl(3600) %}. navVersion = un compteur incrémenté en cache (cache.app) à chaque mutation de catégorie (via un Doctrine listener). Bump de version = nouvelle clé = cache miss propre. Mesure le gain avec le Web Profiler avant/après.
4. Auditer et durcir un template injectable — break-then-fix
Objectif : on te donne un template qui injecte user.name dans un <script>, une URL user dans un href, et un champ "bio" Markdown en |raw. Construis un payload qui exécute du JS dans chacun des trois, puis corrige les trois sans casser le rendu légitime.
Indice/Solution : (a) </script><script>alert(1)// casse le JS inline → corrige avec une interpolation user.name|json_encode|raw ou un data-*. (b) javascript:alert(1) dans le href → valide le schéma (http/https/mailto) côté PHP, sinon #. (c) <img src=x onerror=alert(1)> dans la bio → ne jamais |raw du Markdown brut : passe par un sanitizer (symfony/html-sanitizer, HtmlSanitizer) qui produit du HTML safe, puis marque safe. Bonus : ajoute une CSP script-src 'self' 'nonce-...' et vérifie que même un payload raté ne s'exécute plus.
5. Sandbox pour templates fournis par l'utilisateur — break-then-fix, hard
Objectif : un "email builder" laisse les utilisateurs écrire du Twig. Montre qu'un template user peut leak/détruire des données (interpoler user.email, appeler constant('...'), accéder à app.user), puis verrouille avec SandboxExtension + SecurityPolicy (allowlist de tags/filtres/méthodes/propriétés).
Indice/Solution : sans sandbox, interpoler app.user.password ou un appel de méthode sensible passe. Active SandboxExtension(new SecurityPolicy(allowedTags, allowedFilters, allowedMethods, allowedProperties, allowedFunctions)) et rends en enveloppant le include dans un bloc {% sandbox %} ou via un Environment dédié sandboxé. Toute violation lève SecurityError → log + reject. Teste qu'un tag non autorisé (set interdit, appel de méthode hors allowlist) throw bien.
6. Migration d'un render(controller()) synchrone vers ESI — production-grade, scale
Objectif : une page produit re-rend le bloc "recommandations" (sub-request, ~150 ms, full kernel boot) à chaque hit. Migre vers ESI pour que le reverse proxy cache ce fragment indépendamment de la page, avec un TTL propre.
Indice/Solution : active framework.esi: true, remplace render(controller(...)) par render_esi(controller(...)), mets un Cache-Control: s-maxage=3600 sur la réponse du fragment, et un proxy ESI-aware (Varnish ou HttpCache Symfony). Vérifie via les headers Surrogate-Control / X-Symfony-Cache que le fragment est servi depuis le cache. Compare le temps serveur page-complète avant/après.
🎤 En entretien
Q — Comment l'auto-escaping de Twig protège-t-il du XSS, et où échoue-t-il ? R — une interpolation applique htmlspecialchars(ENT_QUOTES|ENT_SUBSTITUTE), sûr dans un contexte HTML texte ou attribut quoté. Il échoue dans tout contexte non-HTML : JS inline (<script>), URL (href="javascript:"), CSS, event handlers, attributs non quotés — il faut alors |json_encode, validation de schéma, |escape('css'), ou bannir le contexte (CSP). L'auto-escaping est contextuel-HTML, pas universel.
Q — {% include %} vs {% embed %} vs {% use %} vs Twig Component : quand chacun ? R — include = composition simple sans override (un partial). embed = include + possibilité d'override les blocks de l'inclu (card avec header/body customisables). use = "horizontal reuse" : importe les blocks d'un template dans le scope courant sans héritage (rare, pour mixins de blocks). Twig Component = unité réutilisable avec props typées, logique PHP testable, slots — la bonne abstraction pour de l'UI réutilisable moderne ; macros seulement pour des helpers stateless.
Q — Pourquoi Twig est rapide en prod, et qu'est-ce qui peut le ralentir malgré ça ? R — Chaque template est compilé une fois en classe PHP (var/cache/.../twig/) ; à l'exécution c'est de l'echo de string, quasi gratuit. Les ralentissements ne viennent pas du rendu mais : compilation à chaud (warmup au deploy), auto_reload qui stat() à chaque hit, N+1 Doctrine déclenchés dans le template, et render(controller()) non caché qui re-boot le kernel. La logique et l'I/O doivent être résolues avant render().
Q — Tu dois rendre du Twig écrit par tes utilisateurs (CMS, email builder). Quels sont les risques et comment tu sécurises ? R — Sans protection : RCE/leak via appels de méthodes mutantes (obj.delete()), constant(), accès à app.user, etc. dans une interpolation. Solution : SandboxExtension + SecurityPolicy avec allowlist stricte de tags/filtres/fonctions/méthodes/propriétés, rendu dans un Environment dédié, toute violation SecurityError loggée et rejetée. Jamais d'eval-de-Twig non sandboxé sur de l'input externe.