Asset pipeline Symfony — Webpack Encore, AssetMapper, ImportMap
TL;DR Trois mondes coexistent dans Symfony pour livrer le CSS/JS au navigateur. Webpack Encore est l'option historique : bundler Webpack pré-configuré, robuste, compatible avec tout l'écosystème npm, mais demande Node en production (au moins en build) et un step de compilation. AssetMapper (Symfony 6.3+) est la nouvelle voie : pas de bundler, ImportMap natif côté navigateur, modules JS servis tels quels, versionning par hash. Idéal pour les applis modernes (HTTP/2, Brotli, navigateurs récents). ImportMap natif est en réalité le mécanisme sur lequel AssetMapper s'appuie. Garde Encore pour les apps avec build complexe (Sass tree-shaking, splitting fin, dépendances qui exigent un bundler). Migre vers AssetMapper pour la simplicité, la rapidité du dev et l'absence de Node en prod. Tailwind se branche partout, Stimulus aussi.
🧠 Mental model — ASCII + analogie
ENCORE (bundler-based, monde "Webpack")
src/Controller/
assets/app.js ─┐
assets/css/ ─┤ yarn build public/build/
node_modules/ ─┼──────────────► ├── app.abc123.js (bundle)
webpack.config ─┘ ├── app.def456.css (bundle)
└── entrypoints.json (manifest)
Twig ── encore_entry_script_tags() ──► <script src="/build/app.abc123.js">
ASSETMAPPER (bundler-less, monde "ESM natif")
assets/app.js ─┐
assets/controllers/ ─┼─ importmap.php ─► [email protected]
vendor (downloaded) ─┘ │
▼
Twig ── importmap('app') ──► <script type="importmap">{...}</script>
<script type="module">import 'app';</script>
GET /assets/app-HASH.js (servi tel quel)
GET /assets/@hotwired/stimulus-HASH.jsAnalogie : Encore, c'est la cuisine de restaurant — tu prépares un plat complet en cuisine (build), tu sors une assiette servie (bundle minifié, code-splitté). AssetMapper, c'est le buffet — tu mets les ingrédients sur des plateaux (modules ESM versionnés), le client se sert et compose son repas (le navigateur résout les imports via l'importmap). Le buffet exige des convives modernes (HTTP/2, ESM, navigateurs récents), mais zéro vaisselle sale en cuisine.
ImportMap natif est le mécanisme du navigateur (<script type="importmap">) qui mappe import 'lodash' à une URL réelle. AssetMapper l'utilise mais ajoute le téléchargement de packages (importmap:require), le versionning par hash, l'intégration Twig et la stratégie de pré-chargement.
Tableau de décision — le vrai arbitrage
| Critère | Webpack Encore | AssetMapper | ImportMap natif |
|---|---|---|---|
| Bundler / build step JS | Oui (Webpack, ~30-90s CI) | Aucun (asset-map:compile ~1-3s) | Aucun |
| Node en runtime prod | Non (build only) | Non | Non |
| Node en CI | Obligatoire | Optionnel (Tailwind standalone) | Aucun |
| Tree-shaking / dead-code elim | Oui (Terser) | Non (modules servis tels quels) | Non |
| Minification JS | Oui | Non (Brotli compense partiellement) | Non |
| Transpilation (JSX, TS, Babel) | Oui | Non (ESM brut uniquement) | Non |
| Cache granulaire navigateur | Par chunk | Par module (idéal) | Par module |
| Nb de requêtes HTTP | Faible (bundles) | Élevé (1/module) → exige HTTP/2+ | Élevé |
| Audit CVE des deps | npm audit | importmap:audit | Aucun |
| Navigateurs cibles | Tous (polyfillable jusqu'à IE11) | Modernes (Chrome 89+, Safari 16.4+) | Modernes |
| Courbe / surface de debug | Élevée (config Webpack) | Faible | Très faible |
La ligne qui décide presque tout : tree-shaking + minification. AssetMapper ne minifie pas et ne fait pas de tree-shaking. Pour @hotwired/stimulus (12 ko) c'est sans conséquence ; pour moment.js (230 ko) ou une lib qui réexporte 200 helpers dont tu utilises 3, c'est un piège. Le staff engineer raisonne ainsi : « AssetMapper transfère le coût du build vers le réseau, et compte sur Brotli + HTTP/2 + cache immutable pour combler. Tant que mes deps sont ESM-natives et raisonnablement granulaires, le compromis est gagnant. Le jour où j'embarque une lib monolithique non tree-shakeable, je la charge en lazy import() ou je reste sur Encore pour cet entrypoint. »
Comment un staff engineer choisit
- Inventaire des dépendances JS : sont-elles distribuées en ESM ? (
jsdelivr.com/esm, champ"module"/"exports"dupackage.json). Une seule dep CommonJS-only bloquante (DataTables jQuery, vieux Select2) peut justifier de garder Encore ou de chercher un remplaçant ESM. - Poids vs granularité : une lib monolithique de 200 ko sans tree-shaking pèsera son poids brut. Mesure le transfert Brotli réel avant de trancher.
- Cibles navigateur : pas de
<script type="importmap">natif avant Safari 16.4 (mars 2023). Si ta clientèle a des terminaux verrouillés anciens, le polyfilles-module-shimsajoute du JS bloquant — évalue le coût. - Topologie réseau : HTTP/2 ou HTTP/3 de bout en bout (navigateur → CDN → origine) est non négociable pour AssetMapper. Sans multiplexing, le head-of-line blocking de HTTP/1.1 transforme 80 modules en 80 allers-retours sérialisés.
- Coût opérationnel : retirer Node de l'image Docker prod réduit la surface CVE (npm est le premier vecteur de supply-chain attacks) et l'OPEX CI. C'est souvent l'argument décisif côté ops, indépendamment de la perf.
🛠️ Code minimal (PHP 8.2+ + Twig + JS)
Option A — Webpack Encore (legacy mais robuste)
composer require symfony/webpack-encore-bundle
yarn install
yarn add -D @symfony/stimulus-bridge @hotwired/stimulus tailwindcss postcss postcss-loader autoprefixer// webpack.config.js
const Encore = require('@symfony/webpack-encore');
if (!Encore.isRuntimeEnvironmentConfigured()) {
Encore.configureRuntimeEnvironment(process.env.NODE_ENV || 'dev');
}
Encore
.setOutputPath('public/build/')
.setPublicPath('/build')
.addEntry('app', './assets/app.js')
.addEntry('admin', './assets/admin.js')
.splitEntryChunks()
.enableSingleRuntimeChunk()
.cleanupOutputBeforeBuild()
.enableBuildNotifications()
.enableSourceMaps(!Encore.isProduction())
.enableVersioning(Encore.isProduction())
.configureBabel((config) => {
config.plugins.push('@babel/plugin-proposal-class-properties');
})
.configureBabelPresetEnv((config) => {
config.useBuiltIns = 'usage';
config.corejs = '3.34';
})
.enablePostCssLoader()
.enableSassLoader()
.enableIntegrityHashes(Encore.isProduction(), ['sha384'])
.copyFiles({ from: './assets/images', to: 'images/[path][name].[hash:8].[ext]' })
;
module.exports = Encore.getWebpackConfig();// assets/app.js
import './bootstrap.js';
import './styles/app.css';
import { startStimulusApp } from '@symfony/stimulus-bridge';
export const app = startStimulusApp(require.context(
'@symfony/stimulus-bridge/lazy-controller-loader!./controllers',
true,
/\.[jt]sx?$/
));{# templates/base.html.twig #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>{% block title %}App{% endblock %}</title>
{% block stylesheets %}
{{ encore_entry_link_tags('app') }}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
{% block javascripts %}
{{ encore_entry_script_tags('app') }}
{% endblock %}
</body>
</html>Option B — AssetMapper (Symfony 6.3+, sans bundler)
composer require symfony/asset-mapper symfony/asset symfony/stimulus-bundle
# Pas de yarn, pas de node_modules pour le runtime# config/packages/asset_mapper.yaml
framework:
asset_mapper:
paths:
- assets/
missing_import_mode: strict # explose si import non résoluNote :
missing_import_mode: strictlève une exception au build (asset-map:compile) si unimportdu JS ne se résout pas dans l'importmap. À garder en prod : c'est ta seule barrière contre un404silencieux sur un module. La directiveimportmap_polyfilln'existe pas ; le polyfilles-module-shimsest géré automatiquement et configurable via la clées-module-shimsdansimportmap.php.
<?php
// importmap.php (à la racine, équivalent du package.json)
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
'@hotwired/stimulus' => [
'version' => '3.2.2',
],
'@hotwired/turbo' => [
'version' => '8.0.4',
],
'@symfony/stimulus-bundle' => [
'path' => '@symfony/stimulus-bundle/loader.js',
],
'tom-select' => [
'version' => '2.3.1',
],
'tom-select/dist/css/tom-select.default.css' => [
'version' => '2.3.1',
'type' => 'css',
],
];# Ajouter une dépendance
php bin/console importmap:require tom-select
php bin/console importmap:update
php bin/console importmap:audit # vulnérabilités
php bin/console importmap:outdated # versions périmées// assets/app.js
import { startStimulusApp } from '@symfony/stimulus-bundle';
import './bootstrap.js';
import './styles/app.css';
const app = startStimulusApp();{# templates/base.html.twig — AssetMapper #}
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
{% block stylesheets %}{% endblock %}
{% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %}
</head>
<body>{% block body %}{% endblock %}</body>
</html>La fonction importmap('app') génère :
<script type="importmap">
{"imports":{"app":"/assets/app-3f9a.js","@hotwired/stimulus":"/assets/@hotwired/stimulus-7b2c.js"}}
</script>
<link rel="modulepreload" href="/assets/app-3f9a.js">
<link rel="modulepreload" href="/assets/@hotwired/stimulus-7b2c.js">
<script type="module">import 'app';</script>Option C — ImportMap natif (sans AssetMapper)
<!-- Pas recommandé en pratique : pas de versionning, pas de CLI -->
<script type="importmap">
{
"imports": {
"@hotwired/stimulus": "https://unpkg.com/@hotwired/[email protected]/dist/stimulus.js"
}
}
</script>
<script type="module">
import { Application } from '@hotwired/stimulus';
const app = Application.start();
</script>C'est instructif pour comprendre AssetMapper, mais en production tu veux du cache busting, du préchargement et un audit de vulnérabilités — ce que la commande importmap:* apporte.
🎯 Patterns courants
Tailwind avec Encore
// webpack.config.js (extrait)
Encore.enablePostCssLoader();// postcss.config.js
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} },
};// tailwind.config.js
module.exports = {
content: ['./assets/**/*.js', './templates/**/*.html.twig'],
theme: { extend: {} },
plugins: [],
};/* assets/styles/app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;Tailwind avec AssetMapper (deux écoles)
École 1 — CLI standalone (recommandée, zéro Node en runtime, Node seulement pour le build CSS) :
composer require symfonycasts/tailwind-bundle
php bin/console tailwind:init
php bin/console tailwind:build --watch # en dev
php bin/console tailwind:build --minify # en prod, dans le CILe bundle télécharge un binaire Tailwind standalone (pas de npm install). Le CSS compilé est placé dans var/tailwind/tailwind.built.css, AssetMapper le sert avec hash.
École 2 — CDN/build manuel : tu compiles Tailwind toi-même et tu importes le CSS via importmap.php avec 'type' => 'css'.
Stimulus avec Encore
@symfony/stimulus-bridge permet le lazy loading automatique des controllers : un controller référencé par data-controller="search" n'est téléchargé que lorsqu'un élément correspondant entre dans le DOM.
// assets/controllers/search_controller.js
import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller { /* ... */ }Stimulus avec AssetMapper
symfony/stimulus-bundle joue le rôle équivalent : il scanne assets/controllers/ et assets/controllers.json et expose les controllers à Stimulus. Le lazy loading existe aussi via commentaire /* stimulusFetch: 'lazy' */.
// assets/controllers.json — controllers tiers UX
{
"controllers": {
"@symfony/ux-autocomplete": {
"autocomplete": { "enabled": true, "fetch": "eager" }
},
"@symfony/ux-live-component": {
"live": { "enabled": true, "fetch": "eager" }
}
},
"entrypoints": []
}Bundle splitting
Avec Encore, splitEntryChunks() extrait les modules partagés (React, lodash...) dans des chunks séparés, chargés en parallèle. Tu peux aller plus loin avec addEntry multiples (app, admin, checkout) et utiliser dynamic import() pour le code-splitting au runtime.
Avec AssetMapper, le splitting est implicite : chaque module JS est un fichier servi tel quel. Pas de regroupement, donc pas de "chunk commun" à extraire — chaque fichier est mis en cache HTTP individuellement par le navigateur. Sur HTTP/2 multiplexé, c'est efficace ; sur HTTP/1.1 ou si tu as 300 petits modules, c'est sous-optimal.
CDN
Encore et AssetMapper exposent une option setManifestKeyPrefix / framework.assets.base_urls pour servir les fichiers depuis un CDN.
# config/packages/framework.yaml
framework:
assets:
base_urls: ['https://cdn.example.com']
version_strategy: 'App\Asset\HashVersionStrategy'Avec AssetMapper, la commande asset-map:compile génère un dossier public/assets/ qu'il suffit de pousser sur le CDN (S3+CloudFront, Cloudflare R2, etc.). Les noms incluent un hash de contenu, donc le cache CDN peut être en max-age=1 year, immutable.
Cache busting, versioning, integrity (SRI)
- Encore :
enableVersioning(true)ajoute un hash dans le nom (app.abc123.js).enableIntegrityHashes(true, ['sha384'])ajoute l'attributintegrity="sha384-..."aux balises<script>et<link>générées parencore_entry_script_tags. - AssetMapper : versioning intégré, le nom du fichier contient le hash SHA256 du contenu. Pour SRI, ajoute :
framework:
asset_mapper:
importmap_script_attributes:
crossorigin: 'anonymous'Le bundle calcule un integrity pour chaque entrée. Vérifie en HTML que les balises générées ont bien integrity="sha256-...".
Préchargement et stratégie de cache HTTP
Le préchargement est ce qui fait que AssetMapper reste compétitif face à un bundle Webpack tout-en-un. Quand tu appelles la fonction Twig importmap('app'), Symfony génère pour chaque module transitif une balise <link rel="modulepreload">. Le navigateur télécharge alors tous les modules en parallèle, sans attendre que app.js soit parsé pour découvrir ses dépendances. C'est la clé de la perf : sur HTTP/2, dix petits modules téléchargés en parallèle valent mieux qu'un seul gros bundle séquentiel.
{# Ce que tu écris dans le template #}
{{ importmap('app') }}Côté serveur, configure ton reverse proxy (Nginx, Caddy, Cloudfront) pour servir /assets/* avec :
location ^~ /assets/ {
expires 1y;
add_header Cache-Control "public, immutable, max-age=31536000";
add_header Access-Control-Allow-Origin "*"; # si CDN cross-domain
gzip_static on;
brotli_static on;
}Le immutable est crucial : il indique au navigateur que le fichier ne changera jamais sous cette URL (vrai puisque le nom contient un hash de contenu), donc pas de revalidation conditionnelle (If-None-Match).
Stratégies de pré-compilation Brotli/Gzip
Pour des perfs maximales, pré-compresse les assets au build :
# CI/CD : après asset-map:compile
find public/assets -type f \( -name "*.js" -o -name "*.css" -o -name "*.svg" \) -exec brotli -q 11 -f {} \;
find public/assets -type f \( -name "*.js" -o -name "*.css" -o -name "*.svg" \) -exec gzip -9 -k -f {} \;Nginx avec brotli_static on et gzip_static on servira le .br ou .gz selon Accept-Encoding, sans recompresser à la volée.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x + Symfony UX versions
| Composant | 5.4 LTS | 6.4 LTS | 7.x |
|---|---|---|---|
| Webpack Encore Bundle | 1.x (PHP 7.2+) | 2.x (PHP 8.1+) | 2.x |
| AssetMapper | non disponible | 6.3+, stable en 6.4 | recommandé par défaut |
| ImportMap CLI | n/a | importmap:require/update | + importmap:audit/outdated |
@symfony/stimulus-bridge | 3.x | 3.x (Encore uniquement) | 3.x |
symfony/stimulus-bundle | n/a | 2.x (AssetMapper + Encore) | 2.x |
| UX packages (Live, Turbo) | UX 2.x avec Encore | UX 2.x compatible AssetMapper | UX 2.x |
| Symfony Flex | recipes Encore | recipes AssetMapper par défaut | recipes AssetMapper |
Notes :
- Sur Symfony 6.4 LTS, les deux options sont supportées sur la durée du LTS (jusqu'en novembre 2027). Tu peux migrer progressivement.
- AssetMapper en 6.3 était en stabilisation ; en 6.4 c'est production-ready avec SRI, audit et update.
- À partir de 7.0, les recipes Flex installent AssetMapper par défaut. Encore reste totalement supporté, mais c'est un opt-in explicite.
- UX 2.20+ inclut une compatibilité totale AssetMapper, notamment Live Components et Turbo qui scannent les controllers via
assets/controllers.json.
⚠️ Pitfalls — 6-10
- Mélanger Encore et AssetMapper dans la même app sans précaution : les deux peuvent coexister mais tu vas dupliquer Stimulus, Turbo et créer des conflits. Si tu migres, fais-le par étape, mais retire Encore une fois la migration terminée.
importmap.phpversionné mais pasassets/vendor/: par défaut Symfony ignoreassets/vendor/dans.gitignore; cela signifie queimportmap:requiredoit être rejoué en CI/CD pour télécharger les vendors. Décide d'une politique (commit ou rebuild).- CORS sur le CDN : si AssetMapper sert depuis un CDN et que tu utilises
<script type="module">, le serveur CDN doit envoyerAccess-Control-Allow-Origin. Sinon, le navigateur bloque le module. - Tailwind purge mal configurée : oublier d'ajouter
./templates/**/*.html.twigdanscontentdetailwind.config.jsproduit un CSS énorme (toute la lib) ou un CSS qui "perd" des classes utilisées dans Twig. - Lazy controllers et AssetMapper en dev : un controller lazy charge une requête supplémentaire au premier rendu. Sur réseau lent, l'interaction est différée. Solution :
stimulusFetch: 'eager'pour les controllers critiques (modale, navbar). - Compilation oubliée en prod (Encore) : déployer sans
yarn buildou sanspublic/build/packagé dans le release. Symptôme : 404 surentrypoints.json. Vérifie ton CI/CD. asset-map:compileoublié en prod (AssetMapper) : en dev, les assets sont servis dynamiquement par PHP. En prod, tu dois lancerphp bin/console asset-map:compilepour générerpublic/assets/. Sinon Symfony tombe en fallback dynamique et c'est très lent.- Polyfills ESM : pour les navigateurs sans
<script type="importmap">(Safari ancien, certaines versions iOS), AssetMapper recommande le polyfilles-module-shims. Activé par défaut, mais vérifie en cas de support legacy. integritycassé après modification d'un fichier en prod : si tu modifies un asset aprèsasset-map:compile, le hash ne matche plus. Toujours recompiler après changement.- Trop de modules sur HTTP/1.1 : AssetMapper produit potentiellement des dizaines de fichiers. Sur un serveur HTTP/1.1 (ou avec un reverse proxy mal configuré), la latence cumulée tue les perfs. Active HTTP/2 ou HTTP/3 — c'est non négociable pour AssetMapper en prod.
🧪 Testing
Tests de présence des assets
// tests/AssetTest.php
namespace App\Tests;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class AssetTest extends WebTestCase
{
public function testHomepageShipsImportmap(): void
{
$client = static::createClient();
$client->request('GET', '/');
$this->assertResponseIsSuccessful();
$this->assertSelectorExists('script[type="importmap"]');
$this->assertSelectorExists('script[type="module"]');
}
public function testAssetsHaveIntegrity(): void
{
$client = static::createClient();
$crawler = $client->request('GET', '/');
$scripts = $crawler->filter('link[rel="modulepreload"]');
$this->assertGreaterThan(0, $scripts->count());
foreach ($scripts as $node) {
$this->assertNotEmpty($node->getAttribute('integrity'),
'modulepreload sans integrity');
}
}
}Tests perf — Lighthouse + WebPageTest
# Lighthouse CI dans GitHub Actions
npx lhci autorun --collect.url=https://staging.example.com \
--assert.preset=lighthouse:recommendedIndicateurs à surveiller :
- LCP (Largest Contentful Paint) — viser < 2.5s. Avec AssetMapper + HTTP/2 + modulepreload, c'est facile.
- TBT (Total Blocking Time) — viser < 200ms. Stimulus contrôleurs eager peuvent allonger ; passer en lazy si possible.
- Transferred bytes — comparer Encore (bundle gzippé) vs AssetMapper (modules individuels brotli). Sur des apps moyennes, AssetMapper + brotli est compétitif.
Bench micro avec webhint ou vite preview
Lance php bin/console asset-map:compile, sers public/ avec un serveur HTTP/2 (Caddy, Nginx) et mesure :
curl --http2 -w "@curl-format.txt" -o /dev/null -s https://localhost/assets/app-abc.js
# avec curl-format.txt :
# time_namelookup: %{time_namelookup}\n
# time_connect: %{time_connect}\n
# time_starttransfer: %{time_starttransfer}\n
# time_total: %{time_total}\n🎬 Cas d'usage concrets
SaaS RH avec Tailwind multi-thèmes
Une plateforme SaaS de gestion RH (paie, congés, notes de frais) dessert plusieurs centaines d'entreprises clientes. Chaque tenant peut personnaliser sa charte visuelle : couleur primaire, logo, polices. L'équipe historiquement sur Webpack Encore commence à souffrir : yarn build prend 90 secondes en CI, le node_modules pèse 850 Mo, les ingénieurs back se plaignent de devoir installer Node pour développer une simple page Twig. La décision est prise de migrer vers AssetMapper sur les nouveaux modules (onboarding, dashboard manager), tout en gardant Encore sur l'admin legacy pour ne pas tout casser. Tailwind est compilé via le bundle SymfonyCasts standalone : un binaire CLI Go génère le CSS final à partir du JIT, sans Node. Pour gérer le thème par tenant, on injecte des CSS custom properties (--color-primary) dans une balise <style> du layout, avec les valeurs récupérées depuis l'entité Tenant. AssetMapper sert le CSS de base avec un hash de contenu, le CDN CloudFront cache pendant un an avec immutable. Résultat : pages 40% plus rapides au LCP, build CI passé de 90s à 12s (juste composer install + asset-map:compile + tailwind:build --minify), Node retiré complètement de l'image Docker de production.
E-commerce mode haut de gamme avec AssetMapper
Une marque de prêt-à-porter premium lance une nouvelle plateforme e-commerce. Le marketing exige des animations fluides, des transitions soignées entre pages produit, et un Lighthouse score supérieur à 95 sur mobile. L'équipe choisit AssetMapper dès le départ : pas d'historique Encore à porter, navigateurs cibles modernes (la clientèle a des smartphones récents). L'importmap.php liste les dépendances essentielles : @hotwired/turbo, @hotwired/stimulus, embla-carousel pour les sliders produit, motion pour les animations. Les contrôleurs Stimulus sont marqués /* stimulusFetch: 'lazy' */ pour ne charger le code carousel qu'au scroll vers le bloc concerné. Le CDN Cloudflare R2 distribue les assets compilés en HTTP/3 avec Brotli niveau 11 pré-calculé au build. L'image lazy-loading est piloté par un Stimulus controller qui observe l'intersection viewport. Sur les fiches produit, le bouton "ajouter au panier" déclenche un Turbo Stream qui met à jour le mini-panier sans recharger la page. Les Web Vitals mesurés en production : LCP médian à 1.6s sur mobile 4G, INP sous 80ms, CLS quasi nul. L'équipe gagne aussi sur l'OPEX : pas de step Node coûteux en CI, pas de cache node_modules à gérer.
Intranet cabinet juridique avec migration progressive
Un cabinet d'avocats de 200 collaborateurs maintient depuis 2018 un intranet de gestion de dossiers : suivi de litiges, time-tracking, partage documentaire, facturation. L'app tourne en Symfony 5.4 avec Webpack Encore. Le DSI souhaite passer à Symfony 7.x et profiter d'AssetMapper, mais ne peut pas se permettre une refonte big-bang. Stratégie de migration : (1) upgrade Symfony à 6.4 LTS, (2) installation de symfony/asset-mapper en parallèle d'Encore, (3) migration des nouvelles features vers AssetMapper (module de signature électronique, dashboard RGPD), (4) extinction progressive d'Encore sur les modules anciens. Le piège rencontré : un controller Stimulus historique utilisait import jQuery from 'jquery' parce que la lib DataTables jQuery legacy était indispensable. Solution : migration vers Grid.js (ESM-first, sans jQuery), repensée en composants Live pour le filtrage côté serveur. La signature électronique utilise pdf-lib chargée à la demande via importmap:require pdf-lib. Les pages de consultation de dossier (les plus visitées) sont passées en premier et observent une amélioration immédiate du Time to Interactive (de 1.2s à 380ms). L'ops a apprécié pouvoir retirer Node de l'image Docker production, réduisant la surface d'attaque CVE.
🛠️ Exemple end-to-end
Module de recherche de jurisprudence dans l'intranet cabinet juridique. L'utilisateur tape dans un champ de recherche, AssetMapper sert le contrôleur Stimulus, un Turbo Frame affiche les résultats en temps réel, et un dropdown facette permet de filtrer par juridiction.
<?php
// src/Controller/JurisprudenceSearchController.php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\JurisprudenceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_LAWYER')]
final class JurisprudenceSearchController extends AbstractController
{
public function __construct(
private readonly JurisprudenceRepository $repo,
) {}
#[Route('/jurisprudence', name: 'jurisprudence_index', methods: ['GET'])]
public function index(): Response
{
return $this->render('jurisprudence/index.html.twig', [
'jurisdictions' => $this->repo->listJurisdictions(),
]);
}
#[Route('/jurisprudence/search', name: 'jurisprudence_search', methods: ['GET'])]
public function search(Request $request): Response
{
$query = trim((string) $request->query->get('q', ''));
$jurisdiction = $request->query->get('jurisdiction');
$results = $query !== ''
? $this->repo->fullTextSearch($query, $jurisdiction, limit: 20)
: [];
return $this->render('jurisprudence/_results.html.twig', [
'results' => $results,
'query' => $query,
]);
}
}{# templates/jurisprudence/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Recherche jurisprudence{% endblock %}
{% block body %}
<section class="search-container"
data-controller="search-jurisprudence"
data-search-jurisprudence-url-value="{{ path('jurisprudence_search') }}">
<header>
<h1>Recherche de jurisprudence</h1>
</header>
<form class="search-form"
data-action="input->search-jurisprudence#onInput change->search-jurisprudence#onInput">
<input type="search"
name="q"
data-search-jurisprudence-target="input"
placeholder="Mots-clés, numéro d'arrêt, parties..."
autocomplete="off">
<select name="jurisdiction"
data-search-jurisprudence-target="jurisdiction">
<option value="">Toutes juridictions</option>
{% for j in jurisdictions %}
<option value="{{ j.code }}">{{ j.label }}</option>
{% endfor %}
</select>
<div class="spinner" data-search-jurisprudence-target="spinner" hidden>
Recherche en cours...
</div>
</form>
<turbo-frame id="search-results"
data-search-jurisprudence-target="results">
<p class="empty">Saisissez votre requête pour commencer.</p>
</turbo-frame>
</section>
{% endblock %}// assets/controllers/search-jurisprudence_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input', 'jurisdiction', 'results', 'spinner'];
static values = { url: String, minLength: { type: Number, default: 3 } };
initialize() {
this.debounceTimer = null;
this.abortController = null;
}
disconnect() {
clearTimeout(this.debounceTimer);
this.abortController?.abort();
}
onInput() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.fetchResults(), 280);
}
async fetchResults() {
const query = this.inputTarget.value.trim();
if (query.length < this.minLengthValue) {
this.resultsTarget.innerHTML = '<p class="empty">Saisissez au moins 3 caractères.</p>';
return;
}
this.abortController?.abort();
this.abortController = new AbortController();
this.spinnerTarget.hidden = false;
const params = new URLSearchParams({
q: query,
jurisdiction: this.jurisdictionTarget.value,
});
try {
const response = await fetch(`${this.urlValue}?${params}`, {
signal: this.abortController.signal,
headers: { 'Accept': 'text/html' },
});
if (response.ok) {
this.resultsTarget.innerHTML = await response.text();
}
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Recherche échouée', err);
this.resultsTarget.innerHTML = '<p class="error">Erreur lors de la recherche.</p>';
}
} finally {
this.spinnerTarget.hidden = true;
}
}
}// importmap.php
return [
'app' => ['path' => './assets/app.js', 'entrypoint' => true],
'@hotwired/stimulus' => ['version' => '3.2.2'],
'@hotwired/turbo' => ['version' => '8.0.5'],
'@symfony/stimulus-bundle' => ['path' => '@symfony/stimulus-bundle/loader.js'],
];Pipeline CI/CD (extrait GitLab) : composer install --no-dev, php bin/console tailwind:build --minify, php bin/console asset-map:compile, puis Brotli pré-compression et upload S3+CloudFront. Le binaire Docker final ne contient pas Node ; seul PHP-FPM (ou FrankenPHP) tourne en production.
🔁 Quand utiliser / éviter
Garde Webpack Encore si :
- Tu as un build complexe : Sass avec tree-shaking, plugins Webpack custom, dépendances qui ne sont pas distribuées en ESM (encore courant en 2025 mais en voie de disparition).
- Tu vises des navigateurs très anciens (IE11 — rare aujourd'hui — ou Safari < 14) sans vouloir polyfiller.
- Tu as déjà 50+ entrypoints et une équipe formée à Webpack. La migration coûte plus que ce qu'elle rapporte.
- Tu fais du code-splitting agressif avec dynamic
import()et tu profites des chunks communs Webpack pour mutualiser les libs.
Migre vers AssetMapper si :
- App neuve ou refonte.
- Tu veux supprimer Node de la production (le CI peut quand même utiliser Tailwind standalone).
- Ton équipe perd du temps en debugging Webpack.
- Tu vises des navigateurs modernes (Chrome 89+, Firefox 108+, Safari 16.4+ pour
<script type="importmap">natif). - HTTP/2 ou HTTP/3 activé partout (CDN, reverse proxy).
Évite ImportMap natif sans AssetMapper :
- Pour un prototype ou un onepager statique, OK. Sinon le manque de versionning, d'audit et de tooling te rattrape vite.
Plan de migration Encore → AssetMapper
# 1. Sauvegarde
git checkout -b feat/assetmapper
# 2. Installe AssetMapper sans retirer Encore
composer require symfony/asset-mapper
php bin/console assets:install # garantit que public/assets/ existe
# 3. Migre les dépendances une par une
php bin/console importmap:require @hotwired/stimulus
php bin/console importmap:require @hotwired/turbo
php bin/console importmap:require @symfony/stimulus-bundle
# 4. Déplace assets/ vers une structure ESM (assets/app.js, assets/controllers/)
# 5. Crée templates qui utilisent {{ importmap('app') }} en parallèle de
# {{ encore_entry_script_tags('app') }} (test en preview)
# 6. Bascule progressivement template par template
# 7. Une fois tout migré : retire WebpackEncoreBundle
composer remove symfony/webpack-encore-bundle
rm -rf node_modules webpack.config.js package.json yarn.lock public/build
# 8. Adapte le CI : remplace `yarn build` par `php bin/console asset-map:compile`Compte 1 à 3 jours pour une app moyenne. Le piège classique : les controllers tiers (DataTables, Select2 jQuery legacy) qui ne sont pas en ESM. Solution : tom-select, choices.js, alternatives ESM-first.
Diagnostiquer une perf décevante après migration
Si après bascule AssetMapper tu observes des LCP qui grimpent ou un TBT en hausse, voici la check-list pragmatique :
- HTTP/2 activé partout ? Vérifie avec
curl -I --http2 https://app/. Si tu voisHTTP/1.1, ton reverse proxy n'est pas configuré et chaque module va tuer les perfs. - Brotli/Gzip actifs ?
curl -H "Accept-Encoding: br" -I /assets/app-xxx.jsdoit renvoyerContent-Encoding: br. Sans compression, AssetMapper sert du JS brut, parfois 3-5× plus volumineux qu'un bundle Encore minifié+gzippé. - Cache HTTP correct ? Au 2e chargement,
curl -Idoit renvoyerCache-Control: public, immutable. Sinon le navigateur revalide à chaque visite. - Modulepreload présent ? Inspecte le HTML : tu dois voir un
<link rel="modulepreload">par module transitif. Si seulapp.jsest preloadé, tonimportmap()est mal appelé. - Trop de controllers eager ? Stimulus avec 20 controllers eager = 20 fichiers à parser au boot. Bascule les non-critiques en
stimulusFetch: 'lazy'. - CSS bloque le render ? Si tu inclus le CSS via
importmap.php(type css), il est traité en async ; mais via<link rel="stylesheet">standard, il est render-blocking. Le bundle Tailwind doit être chargé en<link>classique, pas via importmap.
Mesure avec npx unlighthouse-cli --site https://staging.example.com --debug pour avoir un rapport multi-pages.
Stratégies avancées : entrypoints multiples
AssetMapper supporte plusieurs entrypoints. Définis-les dans importmap.php :
return [
'app' => ['path' => './assets/app.js', 'entrypoint' => true],
'admin' => ['path' => './assets/admin.js', 'entrypoint' => true],
'auth' => ['path' => './assets/auth.js', 'entrypoint' => true],
// libs partagées (pas entrypoint)
'@hotwired/stimulus' => ['version' => '3.2.2'],
'@hotwired/turbo' => ['version' => '8.0.4'],
];{# templates/admin/base.html.twig #}
{% block javascripts %}
{{ importmap(['app', 'admin']) }}
{% endblock %}
{# templates/security/login.html.twig #}
{% block javascripts %}
{{ importmap('auth') }}
{% endblock %}Symfony génère un seul <script type="importmap"> par page contenant uniquement les imports résolus pour les entrypoints demandés. Cela évite de servir au visiteur public le JS du backoffice.
🔒 Sécurité & supply chain
L'asset pipeline est une surface d'attaque sous-estimée. Trois angles à tenir en prod :
- Supply chain (le vrai risque) : chaque
importmap:requiretélécharge du JS depuis jsDelivr et le commit (ou le rebuild) dansassets/vendor/. Une dep compromise s'exécute dans le navigateur de tous tes utilisateurs (vol de session, keylogging de formulaires). Mitigations :importmap:auditdans le CI (échoue le build sur CVE), revue humaine des bumps de version, et SRI pour figer le hash attendu. Avec AssetMapper, leintegrityest calculé sur le contenu téléchargé : si jsDelivr renvoie un binaire altéré au prochain build sans que tu bumpes la version, le hash change et tu le vois en diff. - CSP (Content-Security-Policy) :
<script type="importmap">et<script type="module">inline cassent une CSP stricte sans'unsafe-inline'. La parade propre est le nonce : lenonce-importmapest propagé par leNonceExtensiondesymfony/asset-mapperquand tu configuresimportmap_script_attributes: { nonce: '...' }. Combine avecscript-src 'strict-dynamic'pour que les modules chargés transitivement héritent de la confiance. - SRI + CORS : si tu sers depuis un CDN cross-origin avec
integrity, le navigateur exigecrossorigin="anonymous"ET un headerAccess-Control-Allow-Origincôté CDN. Oublier l'un des deux = modules bloqués silencieusement (erreur console, page JS-morte).
📈 Observabilité
Tu ne peux pas optimiser ce que tu ne mesures pas. Trois niveaux :
- Build-time :
asset-map:compile --verbosete donne le nombre de fichiers et la taille totale. Pose un garde-fou CI :find public/assets -name '*.js' | wc -let alerte si ça explose (signe d'une dep monolithique qui tire des centaines de sous-modules). - Edge / CDN : surveille le
cache hit ratiodu CDN sur/assets/*. Avec des noms hashés +immutable, tu dois voir > 95 % de HIT en régime stable. Un taux bas signale un cache busting trop agressif (hash qui change à chaque build sans raison) ou unCache-Controlmal configuré. - Real User Monitoring : envoie les Web Vitals (
web-vitalslib, ou l'APIPerformanceObserver) vers ton backend. Le couple à surveiller pour l'asset pipeline est LCP (les assets bloquent-ils le rendu ?) et INP (les controllers Stimulus eager allongent-ils le main thread ?). UnPerformanceResourceTimingfiltré sur/assets/te donne le temps de transfert réel par module en prod, là où Lighthouse ne voit que le synthétique.
🏋️ Exercices
Exercice 1 — Mettre en place AssetMapper from scratch (implement)
Objectif : créer une app Symfony 7 qui sert un controller Stimulus via AssetMapper, sans une ligne de Node. Indice/Solution : composer require symfony/asset-mapper symfony/stimulus-bundle, importmap:require @hotwired/stimulus, crée assets/app.js qui appelle startStimulusApp(), un assets/controllers/hello_controller.js, puis appelle la fonction Twig importmap('app') dans le layout. Vérifie en DevTools : un <script type="importmap"> + un <link rel="modulepreload"> par module.
Exercice 2 — Pré-compression Brotli/Gzip dans le CI (production-grade)
Objectif : après asset-map:compile, générer les .br/.gz et faire servir le pré-compressé par Nginx. Indice/Solution : pipeline asset-map:compile → find public/assets -type f \( -name '*.js' -o -name '*.css' \) -exec brotli -q 11 -f {} \;. Côté Nginx, brotli_static on; gzip_static on; dans le location ^~ /assets/. Valide avec curl -H "Accept-Encoding: br" -I → Content-Encoding: br et un Content-Length réduit.
Exercice 3 — Mesurer Encore vs AssetMapper (analyse comparative)
Objectif : sur la même app, comparer le poids transféré et le LCP entre un bundle Encore minifié+gzippé et AssetMapper + Brotli. Indice/Solution : build les deux variantes, sers chacune derrière un proxy HTTP/2, lance lhci autorun sur les deux. Tableau attendu : Encore gagne en octets bruts (tree-shaking), AssetMapper gagne en cache granulaire au 2e chargement (un seul module change = un seul refetch). Conclus selon le profil de trafic (premières visites vs récurrentes).
Exercice 4 — Casser puis réparer le cache busting (break-then-fix)
Objectif : reproduire le bug du « vieux JS servi après déploiement », puis le corriger. Indice/Solution : sers /assets/app.js (sans hash) avec Cache-Control: max-age=31536000 SANS immutable ni nom hashé → après déploiement les clients gardent l'ancien JS. Fix : repasse par la fonction Twig importmap('app') (nom hashé) + immutable. Démontre avec deux asset-map:compile successifs que l'URL change quand le contenu change, et reste stable sinon.
Exercice 5 — Embarquer une dépendance et la sécuriser (production-grade)
Objectif : ajouter tom-select, activer l'audit CVE et le SRI, puis simuler une CVE bloquante. Indice/Solution : importmap:require tom-select, ajoute importmap:audit au CI (exit non-zéro sur vuln). Configure importmap_script_attributes: { crossorigin: 'anonymous' }. Pour simuler : épingle une vieille version connue vulnérable et vérifie que le CI échoue. Vérifie en HTML la présence de integrity="sha256-...".
Exercice 6 — CSP stricte avec nonce (break-then-fix, hard)
Objectif : activer Content-Security-Policy: script-src 'self' et faire fonctionner l'importmap inline sans 'unsafe-inline'. Indice/Solution : pose la CSP via un kernel.response listener. Au début tout casse (importmap inline bloqué). Génère un nonce par requête, injecte-le dans la CSP ET dans importmap_script_attributes: { nonce: 'auto' }. Ajoute 'strict-dynamic' pour que les modules transitifs héritent. Vérifie : zéro erreur CSP en console, page fonctionnelle.
🎤 En entretien
Q : Pourquoi AssetMapper ne minifie-t-il pas ni ne fait de tree-shaking, et est-ce un problème ? R : Parce qu'il n'y a pas de bundler — les modules ESM sont servis tels quels. Ce n'est pas un problème tant que les deps sont ESM-natives et granulaires : Brotli + HTTP/2 + cache immutable compensent le poids brut, et le cache par-module bat le cache par-bundle au 2e chargement. Ça le devient avec une lib monolithique non tree-shakeable — qu'on charge alors en lazy import() ou qu'on garde sur Encore.
Q : En quoi le préchargement (modulepreload) est-il critique pour la perf d'AssetMapper ? R : Sans bundle, le navigateur découvre les dépendances en parsant app.js, puis celles de ses dépendances, en cascade séquentielle (waterfall). importmap() émet un <link rel="modulepreload"> pour chaque module transitif d'avance, donc le navigateur télécharge tout le graphe en parallèle dès le premier octet de HTML. Sur HTTP/2 multiplexé, c'est ce qui rend N petits modules compétitifs face à un gros bundle.
Q : Comment garantis-tu qu'un déploiement ne sert pas l'ancien JS aux clients (cache busting) ? R : Le nom de fichier contient un hash SHA du contenu (app-3f9a.js). Une modif change le hash donc l'URL ; l'ancienne URL n'est plus référencée. On sert avec Cache-Control: public, immutable, max-age=1y : immutable évite même la revalidation conditionnelle. L'HTML (non hashé, jamais en cache long) porte le nouveau mapping via l'importmap.
Q : Quels sont les risques de sécurité spécifiques à l'asset pipeline et comment les couvres-tu ? R : Supply chain en tête — chaque dep s'exécute dans le navigateur des users. Je couvre avec importmap:audit en CI (fail sur CVE), SRI (integrity figé sur le hash de contenu, détecte une altération CDN), et une CSP stricte par nonce pour les scripts inline importmap/module, avec 'strict-dynamic' pour les modules transitifs. CORS + crossorigin="anonymous" obligatoires si le CDN est cross-origin.
🔗 Liens
- Documentation officielle AssetMapper : https://symfony.com/doc/current/frontend/asset_mapper.html
- Documentation Webpack Encore : https://symfony.com/doc/current/frontend/encore/installation.html
- Symfony UX et AssetMapper : https://ux.symfony.com/
- ImportMaps natifs (MDN) : https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
- Tailwind Bundle : https://github.com/SymfonyCasts/tailwind-bundle
- Article SymfonyCasts "Encore vs AssetMapper" : https://symfonycasts.com/screencast/asset-mapper
- ECMAScript modules in browsers : https://web.dev/articles/modulepreload
- Caddy serveur HTTP/2 + brotli : https://caddyserver.com/docs/quick-starts/static-files
importmap:audit(CVE feed) : https://symfony.com/blog/new-in-symfony-6-4-importmap-audit-and-outdated-commandses-module-shimspolyfill : https://github.com/guybedford/es-module-shims