Migrer de Webpack Encore vers Vite avec Reprise
Nous maintenons, pour un de nos clients, un projet à fort trafic : 5 sites servis par une même application Symfony, 800 templates Twig, et un front React + Tailwind buildé depuis des années par Webpack Encore. Un build front qui commençait sérieusement à peser : plus d’une minute de Webpack, un NODE_OPTIONS=--max_old_space_size=4096 pour ne pas exploser la heap, un webpack.config.js de 200 lignes pilotant deux builds distincts, et aucun hot reload pour les développeurs.
Webpack Encore arrive en fin de vie, et Symfony a publié son successeur officiel pour Vite et Rsbuild : Reprise. Le bundle était encore marqué expérimental quand nous avons migré (en 0.7, puis 0.8), mais c’était clairement la direction que prend le framework. Nous avons donc sauté le pas. Et depuis, la 1.0 est sortie, apportant ainsi la même promesse de rétrocompatibilité que Symfony.
Je vais vous raconter dans cet article comment s’est passée cette migration. Nous verrons d’abord ce que fait Reprise et ce qu’implique la migration mécanique, puis les vrais sujets qui nous ont occupés : le contrat implicite que notre base de code avait avec Webpack, une collection de « webpack-ismes » qui ne se révèlent qu’au runtime, et enfin la mise en place du hot reload dans notre stack Docker. Pour vous donner envie de lire jusqu’au bout : le build est passé de 75–100 secondes à une quinzaine de secondes, et nous avons supprimé 583 packages npm au passage 🎉.
Section intitulée vous-avez-dit-repriseVous avez dit Reprise ?
Contrairement à Encore qui réimplémentait toute la chaîne de build par-dessus Webpack, Reprise ne fournit que la glue Symfony : la génération d’entrypoints.json et de manifest.json, les fonctions Twig reprise_entry_*, et le support du dev server. Tout le reste (Sass, TypeScript, React, code splitting, minification), c’est Vite qui le fait nativement.
La migration mécanique est vite pliée :
composer remove symfony/webpack-encore-bundle
composer require symfony/reprise
yarn add --dev vite @symfony/reprise
Un vite.config.ts par build, encore_entry_script_tags qui devient reprise_entry_script_tags dans les templates, et c’est à peu près tout. Sur le papier, quelques heures de travail.
Le contraste entre les deux configs illustre bien la philosophie. Avant, nous avions 200 lignes d’API chaînée Encore, où chaque capacité du bundler doit être déclarée explicitement :
// webpack.config.js (extrait)
Encore.setOutputPath('web/build/')
.setPublicPath('/build')
.addEntry('js/app', './assets/scripts/main.tsx')
.addStyleEntry('css/app', './assets/styles/main.css')
// … 6 autres entrées
.disableSingleRuntimeChunk()
.enablePostCssLoader()
.enableReactPreset()
.enableTypeScriptLoader()
.autoProvideVariables({ 'bazinga-translator': 'Translator' })
.addPlugin(new ESLintPlugin())
.addPlugin(new StylelintPlugin({ /* … */ }))
.copyFiles([{ from: './assets/images', to: 'images/[path][name].[ext]?[hash:8]' }])
.configureFilenames({ js: '[name].js?[chunkhash]', /* … */ });
Après ? Une cinquantaine de lignes où Vite fait le gros du travail nativement. TypeScript et React n’ont plus besoin de loader, et les plugins ESLint/Stylelint sont remplacés par les scripts lint déjà présents dans la CI :
// vite.config.ts (extrait)
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode !== 'production',
rollupOptions: {
input: {
'js/app': './assets/scripts/main.tsx',
'css/app': './assets/styles/main.css',
// … 6 autres entrées
},
},
},
plugins: [
react(),
tailwindcss(),
symfony({ outputPath: 'web/build', publicPath: '/build/' }),
copyStable([{ from: 'assets/images', to: 'images' }], '/build/', 'web/build'),
],
}));
Vous remarquez le copyStable sur la dernière ligne ? Il n’est pas fourni par Reprise, et c’est justement le sujet du chapitre suivant. Car comme nous allons le voir, le vrai sujet d’une migration de bundler n’est pas la config du bundler en elle-même.
Section intitulée le-contrat-implicite-avec-webpackLe contrat implicite avec Webpack
Vite hashe les noms de fichiers par défaut, c’est son modèle de cache-busting : app.js devient app-B7fAYn0O.js, et le manifest.json fait la correspondance. Sauf que notre projet avait un contrat exactement inverse, invisible tant qu’on ne le cherche pas :
- plus de 300 templates référencent des images en dur, façon
asset('/build/images/logo.svg'), sans jamais passer par un manifest ; - près de 250 templates utilisent un filtre Twig maison
|inlinequi lit les SVG directement sur le disque pour les inliner dans le HTML ; - le cache-busting est global, par query string, à partir d’un fichier
REVISIONproduit au déploiement.
Autrement dit, les chemins physiques des fichiers copiés doivent rester stables, et ce depuis des années. Réécrire 300 templates n’était évidemment pas envisageable. De plus, les emails envoyés par l’application utilisent également certains de ces assets (notamment les fonts), ils doivent donc rester disponibles aux mêmes urls.
Reprise proposait bien (en 0.7) une option copy pour remplacer le copyFiles() d’Encore, mais elle hashait systématiquement les noms de fichiers copiés, sans opt-out. Encore laissait choisir son pattern (images/[path][name].[ext]?[hash:8] chez nous : chemin stable sur disque, hash en query string). Notre réponse initiale a tenu en un plugin Vite d’une quarantaine de lignes, qui reproduisait le contrat d’Encore :
// vite-plugin-copy-stable.ts (extrait)
generateBundle(_options, bundle) {
for (const { file, logicalName } of files(entries)) {
const source = readFileSync(file);
const hash = createHash('sha256').update(source).digest('hex').slice(0, 8);
// Le fichier garde son chemin logique…
this.emitFile({ type: 'asset', fileName: logicalName, source });
// … et le hash part dans la valeur du manifest, en query string
manifestEntries[keyPrefix + logicalName] = `${publicPath}${logicalName}?${hash}`;
}
// puis fusion de manifestEntries dans le manifest.json émis par Reprise
}
Résultat : zéro template modifié (hors les 4 layouts de base), le JS et le CSS profitent du hashing natif de Vite via entrypoints.json, et tout le reste garde ses chemins stables.
Ce besoin nous a semblé suffisamment universel pour le proposer upstream : symfony/reprise#81 ajoute une option hash: false par entrée copy, qui reproduit exactement ce contrat. Elle a été mergée et publiée dans Reprise 0.8 quelques jours plus tard : nos quarante lignes de plugin ont disparu au profit d’une ligne de config, avec des arborescences et des manifests strictement identiques.
Astuce
Avant d’estimer une migration de bundler, inventoriez qui consomme vos assets et par quel canal (manifest, chemins en dur, lecture disque, CDN). C’est là que se cache la vraie charge de travail, pas dans la config.
Section intitulée les-webpack-ismes-qui-ne-se-voient-qu-au-runtimeLes webpack-ismes qui ne se voient qu’au runtime
Une fois le build vert, nous pensions être tirés d’affaires. Mais la CI nous attendait au tournant, avec de nombreux scénarios Behat en échec. Tous les problèmes que je vais lister ici ont le même point commun : le build passe, le typecheck passe, et pourtant le site ne fonctionne plus au runtime.
Section intitulée code-global-code-n-existe-pasglobal n’existe pas
global.Translator = Translator;
Webpack aliasse silencieusement global vers window. Vite, non :
Uncaught ReferenceError: global is not defined
L’erreur survient au top-level du module, donc c’est tout le bundle qui meurt : plus une seule ligne de JS ne s’exécute sur le site. Le plus vicieux : @types/node étant installé, global est parfaitement typé et tsc ne bronche pas. Le fix est trivial (window.Translator = …), encore faut-il savoir que ces assignations existent.
Astuce
Cherchez global. dans votre code avant de migrer, cela vous évitera de découvrir le problème en CI comme nous.
Section intitulée les-code-require-code-dynamiquesLes require() dynamiques
<ReactSVG src={require(`../../../images/icons/${path}`)} />
Ce pattern repose sur les context modules de Webpack, qui embarquait tout le dossier icons/ pour résoudre l’expression au runtime. Vite ne les implémente pas :
Uncaught ReferenceError: require is not defined
L’exception éclate au premier rendu d’un composant avec icône, et React réagit en démontant tout l’arbre : pages de résultats intégralement vides, sans un message pour l’utilisateur. Nos icônes étant déjà copiées à chemins stables (voir plus haut), une URL directe a suffi : src={/build/images/icons/${path}}.
Section intitulée les-code-url-code-css-qui-ne-suivent-pasLes url() CSS qui ne suivent pas
Avec @tailwindcss/postcss, les @import CSS sont inlinés sans rebaser les chemins relatifs. Un url('../../fonts/brand-400.woff2') écrit dans un fichier importé se retrouve tel quel dans le CSS final, se résout depuis la racine et répond 404 : webfonts et images de fond ont disparues.
Le plugin officiel @tailwindcss/vite réécrit ces mêmes URLs vers l’asset émis ( url(/build/brand-400-Dm0XPNJo.woff2)) en plus d’être plus rapide. C’est l’intégration que Tailwind recommande quand on build avec Vite. Le rebasing côté PostCSS a bien été corrigé à plusieurs reprises upstream (voir l’issue #16636, fermée depuis), mais un fichier importé depuis notre propre code nous rejouait toujours le problème en 4.3.3. Je ne peux que vous recommander de ne plus passer par PostCSS si vous utilisez Tailwind v4 avec Vite.
Section intitulée le-fichier-vendor-en-commonjsLe fichier vendor en CommonJS
import Routing from '../../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js';
Celui-là est retors : il fonctionne en build (le plugin commonjs de Rollup fait l’interop), mais crashe uniquement en dev :
The requested module '…/router.min.js' does not provide an export named 'default'
Vite ne prébundle que node_modules, et sert donc le fichier CJS de vendor/ tel quel à un navigateur qui attend un module ES. La solution est le package npm fos-router, qui est exactement le même que celui packagé dans le bundle Symfony. En prime, le package npm est en typescript : tsc a immédiatement débusqué une dizaine de window.location = url qui dormaient depuis des années (le module vendor étant any, tout ce qui en sortait échappait au typage).
Astuce
Ces quatre pannes ont un point commun : elles ne laissent aucune trace côté serveur. La page répond 200, les logs Symfony sont vides, et le symptôme n’existe que dans la console du navigateur : un pageerror, un console.error, ou une requête d’asset en échec.
Si vous avez des tests e2e, branchez-y les listeners pageerror, console et requestfailed de Playwright, ne serait-ce que le temps de la migration : c’est le filet qui transforme ces bugs silencieux en tests rouges, au lieu de vous les faire découvrir à l’œil nu, page par page.
Section intitulée quand-le-site-depend-d-un-bug-du-bundlerQuand le site dépend d’un bug du bundler
Voici mon anecdote préférée de cette migration. Après le passage à Vite, un test de régression visuelle refusait obstinément de passer : sur une déclinaison du site, le logo s’affichait 60 % trop grand.
Nous avons tout vérifié : le fichier SVG copié, identique ; les règles CSS, identiques et dans le même ordre ; le HTML, identique. En dernier recours, le manifest Webpack de production, qui racontait une drôle d’histoire :
"build/images/logo-pro.svg": "/build/images/logo-pro.e42c1969.svg"
Un hash dans le nom de fichier, là où toutes les autres entrées copiées utilisaient une query string. Et le contenu de ce fichier hashé n’était pas le fichier demandé : c’était un autre SVG du même nom, situé dans un sous-dossier icons/, avec une classe CSS différente, donc une taille différente.
L’explication : les images émises par file-loader (dont tout le dossier icons/, embarqué par le require() dynamique vu plus haut) écrasaient les clés du manifest des fichiers copiés portant le même nom. Depuis des années, le site servait le mauvais fichier à cet endroit, et ce rendu accidentel était devenu la référence, jusque dans nos baselines de screenshots. Notre build Vite, plus propre, servait enfin le bon fichier… et cassait donc le test.
Nous avons audité les 31 collisions de noms du projet (une seule autre était visible) et pointé les templates vers le bon fichier, explicitement. Ce que je retiens de cette histoire, c’est qu’un bundler est avant tout un système de résolution : en changer révèle toutes les résolutions accidentelles dont votre site dépend sans que vous le sachiez.
Section intitulée un-pixel-de-differenceUn pixel de différence
Toujours côté régressions visuelles : trois baselines ont bougé d’exactement 1 pixel après la migration. Un bouton centré, dont la largeur totale (icône dimensionnée en em + texte) tombe à un demi-pixel près différemment. En cause, le changement de minifieur CSS : cssnano (configuré avec calc: false précisément pour éviter ce genre d’arrondis) a laissé la place à esbuild.
0,01 % des pixels, invisible à l’œil, mais parfaitement reproductible. Le rendu de Vite est déterministe au pixel près d’un run à l’autre : nous avons comparé des captures à deux jours d’écart, zéro pixel de différence.
Corollaire qui vaut pour tous ceux qui font des tests de screenshots : ne régénérez jamais vos baselines sur le dev server. Le CSS non minifié y produit les mêmes écarts d’arrondis face au rendu buildé que compare votre CI, et vous chercherez longtemps pourquoi « ça passe en local ». Chez nous, la task de mise à jour des screenshots rebuild d’office avant de capturer.
Section intitulée brancher-le-hmr-dans-la-stack-dockerBrancher le HMR dans la stack Docker
Le HMR (Hot Module Replacement ou remplacement de module à chaud) est le gain le plus visible de la migration pour les développeurs, et il mérite qu’on s’y attarde. Petite confession d’abord : avec Encore, notre « watch » se contentait d’écrire les fichiers sur le disque, et le vrai dev-server était pensé pour tourner dans Docker pour les PC sous Linux, sur la machine hôte (donc en dehors de Docker) pour les Macs (car ceux de l’époque souffraient trop). Avec Vite, nous voulions le HMR dans la stack, comme tout le reste, et pour tout le monde.
Section intitulée les-problemes-rencontresLes problèmes rencontrés
Nous avons eu trois problèmes à résoudre.
Atteindre le serveur depuis le navigateur. Nous avons d’abord sur-conçu la chose : route Traefik dédiée, TLS, service discovery. Puis nous avons retenu une solution bien plus simple, déjà adoptée sur un autre de nos projets : publier le port du conteneur sur l’hôte et servir en http://localhost:5173. Les navigateurs traitent localhost comme une origine sûre, donc pas de mixed content depuis une page HTTPS et zéro certificat à gérer.
Le cycle de vie. Notre premier montage lançait Vite dans un conteneur compose run éphémère. Mauvaise idée : un watch tué laisse un zombie qui squatte le port 5173 et continue d’écraser les fichiers en douce. Nous avons donc plutôt défini un service Compose dédié : up réutilise ou recrée toujours le même conteneur, ce qui rend le zombie impossible.
# docker-compose.dev.yml (extrait)
vite:
image: "${PROJECT_NAME}-builder"
command: bash -c "until [ -d node_modules/.bin ]; do sleep 2; done; yarn run ${VITE_SCRIPT:-dev}"
ports:
- "127.0.0.1:${PROJECT_VITE_PORT:-5173}:5173"
Le until node_modules n’est pas décoratif : au premier démarrage de la stack, Docker démarre le service avant que yarn install ne soit passé, et on ne veut pas d’un service en crash-loop.
Les pièges du conteneur. Deux classiques à connaître. Le watcher de Vite crawle par défaut tout le projet : avec un vendor/ de 100 000 fichiers, la limite inotify explose. Il faut donc l’exclure explicitement. Et méfiez-vous de vos patterns d’exclusion : notre **/var/**, pensé pour le cache Symfony, matchait /var/www, le dossier de travail du conteneur. Tout le projet était ignoré par le watcher, donc le HMR était inopérant : le serveur tourne, la page se charge, et rien ne se met à jour. Ancrez vos patterns au dossier du projet :
watch: {
ignored: ['vendor', 'var', 'web'].map((dir) => path.resolve(__dirname, dir, '**')),
},
Dernier raffinement, la cohérence entre les modes : nos tasks de build stoppent le serveur de dev s’il tourne (sinon les pages repassent sur les assets buildés pendant qu’un serveur orphelin continue de tourner pour rien), et la task de watch le démarre. On ne peut ainsi pas se retrouver dans un état intermédiaire sans le savoir.
Section intitulée et-les-worktreesEt les worktrees ?
Avec l’IA devenant progressivement incontournable pour gagner en efficacité, nous travaillons de plus en plus avec des worktrees git, chacun avec sa stack Docker complète et isolée. C’est une feature native de notre template docker-starter : le nom de projet Compose est suffixé par le worktree, et tous les ports hôtes sont décalés automatiquement, ce qui permet d’avoir plusieurs stacks qui tournent en parallèle.
Le port du serveur Vite rejoint simplement ce mécanisme : chaque worktree a le sien, et deux développements en parallèle ont chacun leur HMR.
Il restait un détail à régler : Symfony génère des URLs d’assets absolues à partir des base_urls configurées, qui ne connaissent pas le port décalé. Dans un worktree, les pages allaient donc chercher leurs assets sur la stack du checkout principal, et on a mis un moment à comprendre pourquoi. Notre solution : un suffixe de port paramétrable dans les base_urls, vide par défaut (et en production) :
# config/packages/framework.yaml
framework:
assets:
base_urls:
- 'https://%http.domain.front%%http.public_port_suffix%'
Et plutôt que de demander à chaque développeur de le renseigner à la main, la task Castor 🦫 qui démarre la stack détecte le worktree et synchronise la valeur dans un fichier parameters_override.yaml local (gitignoré, prévu pour la config personnelle) :
Info
Notre projet utilise encore des fichiers parameters.yaml, nous n’avons pas migré vers des variables d’environnement et les .env associés.
// Extrait de la task, appelée par `castor up`
$line = \sprintf("http.public_port_suffix: ':%d'", get_worktree_ports(get_worktree_name())['https']);
// … créé ou mis à jour dans parameters_override.yaml, sans toucher aux autres clés
Un nouveau worktree est ainsi utilisable en une seule commande, HMR compris.
Section intitulée ce-que-nous-avons-perdu-au-passageCe que nous avons perdu au passage
Pour être complet, voici ce que la migration nous a coûté :
- la minification svgo des images copiées a été abandonnée, à refaire à la source si le poids devient un sujet ;
- ts-loader faisait une analyse statique de code à chaque build, mais Vite ne le fait pas. Nous avons donc ajouté une nouvelle step dans la CI qui lance un
yarn tsc --noEmitpour combler le trou (ne l’oubliez pas, c’est un vrai filet qui disparaît sinon) ; - Reprise était expérimental au moment de la migration, avec une API susceptible de bouger d’une version à l’autre. Ce point s’est réglé tout seul depuis : la 1.0 est sortie et adopte la promesse de rétrocompatibilité de Symfony. Notre montée depuis la 0.8 s’est résumée à changer la contrainte de version, sans une ligne de code à toucher.
Section intitulée une-migration-largement-deleguee-a-l-iaUne migration largement déléguée à l’IA
Un dernier point qui vous intéressera peut-être : nous avons laissé une IA faire le gros de cette migration. Pas la décision de migrer ni les choix structurants (le contrat d’assets, le mode de câblage du HMR), mais l’essentiel du travail mécanique et surtout l’itération sur les problèmes rencontrés.
Ce qui a rendu cela possible, ce n’est pas l’IA elle-même, c’est le filet de sécurité qui existait déjà autour du projet : une CI complète avec Behat, PHPUnit et nos tests e2e Playwright avec screenshots. Chaque webpack-isme du chapitre précédent a été détecté par un test, pas par un humain : les 19 scénarios Behat rouges pour le global fantôme, les screenshots pour le logo trop grand ou le pixel de différence, les 404 des fonts dans les captures. À chaque fois, l’IA a pu lire le rapport, reproduire le problème en local, corriger, et relancer la CI, sans que nous ayons à intervenir autrement que pour valider les choix.
Sans cette couverture de tests, la même migration aurait demandé une relecture visuelle de dizaines de pages à chaque itération, et nous n’aurions probablement pas osé déléguer autant. C’est un bon argument, si vous en manquiez, pour investir dans des tests de régression visuelle avant d’entreprendre ce genre de chantier.
Section intitulée conclusionConclusion
Nous avons vu dans cet article que la migration mécanique d’Encore vers Reprise tient en quelques heures, et que le vrai travail se joue ailleurs : dans le contrat implicite que votre base de code entretient avec son bundler, et dans les quelques webpack-ismes qui ne se révèlent qu’au runtime. C’est là qu’il faut chercher si vous devez estimer une telle migration.
Le résultat en vaut la peine : notre build est cinq à six fois plus rapide, nous repassons sur la config par défaut de node avec 1 Go de heap (au lieu des 4 Go nécessaires auparavant), nous avons supprimé 583 packages npm, la config a été divisée par trois, et les développeurs ont enfin un hot reload avec fast-refresh React. En bonus, cela signifie également que le déploiement est également plus rapide de plus d’une minute, c’est appréciable !
Être early adopter d’un bundle expérimental a aussi ses bons côtés : le principal point de friction rencontré a fini en contribution upstream, mergée et publiée en quelques jours. La prochaine équipe qui migrera un site aux chemins d’assets figés aura une option de config là où nous avions initialement écrit un plugin local.
Commentaires et discussions
Nos articles sur le même sujet
Détecter les régressions visuelles dans la CI avec Playwright et Docker
Mise à jour 25/08/2026 : ajout d’une explication quand il est préférable d’utiliser du CSS custom au lieu d’appliquer un masque Playwright. Sur un gros site public, le front bouge tout le temps : une migration…
par Loïck Piera
Optimiser webpack dans la CI
La compilation des assets avec webpack est une tâche qui prend souvent beaucoup de temps. À chaque build du projet dans la CI, il faut re-compiler ces assets, encore et encore (pun intended). Il est possible de…
par Grégoire Pineau
Nos formations sur ce sujet
Notre expertise est aussi disponible sous forme de formations professionnelles !
Symfony
Formez-vous à Symfony, l’un des frameworks Web PHP les complet au monde
Symfony avancée
Découvrez les fonctionnalités et concepts avancés de Symfony
Ces clients ont profité de notre expertise
Nous avons construit un extranet afin de de simplifier les tâches quotidiennes de gestion, que ce soit pour les utilisateurs (départements, associations, mandataires, accueillants et accueillis) et l’équipe de Cettefamille. Le socle technique utilisé est Symfony, PostgreSQL, Webpack, VanillaJS. L’emploi de ces technologies modernes permet aujourd’hui…
Pour améliorer les performances et la pertinence des recherches sur le site e-commerce, JoliCode a réalisé un audit approfondi du moteur Elasticsearch existant. Nous avons optimisé les processus d’indexation, réduisant considérablement les temps nécessaires tout en minimisant les requêtes inutiles. Nous avons également ajusté les analyses pour mieux…
Afin de soutenir le développement de son trafic, Qobuz a fait appel à JoliCode afin d’optimiser l’infrastructure technique du site et les échanges d’informations entre les composants de la plateforme. Suite à la mise en place de solution favorisant l’asynchronicité et la performance Web côté serveur, nous avons outillé la recherche de performance et…