Migrating from Webpack Encore to Vite with Reprise
We maintain a high-traffic project for one of our clients: 5 websites served by a single Symfony application, 800 Twig templates, and a React + Tailwind front end that has been built by Webpack Encore for years. A front-end build that was starting to weigh on us: over a minute of Webpack, a NODE_OPTIONS=--max_old_space_size=4096 to keep the heap from blowing up, a 200-line webpack.config.js driving two separate builds, and no hot reload for developers.
Webpack Encore is reaching the end of its life, and Symfony has released its official successor for Vite and Rsbuild: Reprise. The bundle was still marked experimental when we migrated (in 0.7, then 0.8), but it was clearly the direction the framework is taking. So we took the plunge — and since then, 1.0 has been released, with the same backward compatibility promise as Symfony.
In this article, I’m going to tell you how that migration went. We’ll first look at what Reprise does and what the mechanical migration involves, then at the real topics that kept us busy: the implicit contract our code base had with Webpack, a collection of “webpack-isms” that only show up at runtime, and finally setting up hot reload in our Docker stack. To make you want to read all the way through: the build went from 75–100 seconds to about fifteen seconds, and we removed 583 npm packages along the way 🎉.
Section intitulée did-you-say-repriseDid you say Reprise?
Unlike Encore, which reimplemented the whole build chain on top of Webpack, Reprise only provides the Symfony glue: generating entrypoints.json and manifest.json, the reprise_entry_* Twig functions, and dev server support. Everything else (Sass, TypeScript, React, code splitting, minification) is handled natively by Vite.
The mechanical migration is quickly done:
composer remove symfony/webpack-encore-bundle
composer require symfony/reprise
yarn add --dev vite @symfony/reprise
One vite.config.ts per build, encore_entry_script_tags becoming reprise_entry_script_tags in the templates, and that’s pretty much it. On paper, a few hours of work.
The contrast between the two configs illustrates the philosophy nicely. Before, we had 200 lines of Encore’s chained API, where every bundler capability has to be declared explicitly:
// webpack.config.js (excerpt)
Encore.setOutputPath('web/build/')
.setPublicPath('/build')
.addEntry('js/app', './assets/scripts/main.tsx')
.addStyleEntry('css/app', './assets/styles/main.css')
// … 6 more entries
.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]', /* … */ });
And after? About fifty lines, where Vite does the heavy lifting natively. TypeScript and React no longer need a loader, and the ESLint/Stylelint plugins are replaced by the lint scripts already present in the CI:
// vite.config.ts (excerpt)
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode !== 'production',
rollupOptions: {
input: {
'js/app': './assets/scripts/main.tsx',
'css/app': './assets/styles/main.css',
// … 6 more entries
},
},
},
plugins: [
react(),
tailwindcss(),
symfony({ outputPath: 'web/build', publicPath: '/build/' }),
copyStable([{ from: 'assets/images', to: 'images' }], '/build/', 'web/build'),
],
}));
Did you notice the copyStable on the last line? It isn’t provided by Reprise, and that’s precisely the subject of the next chapter. Because as we’re about to see, the real topic of a bundler migration isn’t the bundler config itself.
Section intitulée the-implicit-contract-with-webpackThe implicit contract with Webpack
Vite hashes file names by default, that’s its cache-busting model: app.js becomes app-B7fAYn0O.js, and manifest.json maps between the two. Except that our project had exactly the opposite contract, invisible as long as you don’t go looking for it:
- more than 300 templates reference images with hardcoded paths, along the lines of
asset('/build/images/logo.svg'), never going through a manifest; - close to 250 templates use a home-made
|inlineTwig filter that reads SVGs straight from disk to inline them in the HTML; - cache-busting is global, through a query string, based on a
REVISIONfile produced at deploy time.
In other words, the physical paths of the copied files have to stay stable, and have done so for years. Rewriting 300 templates was obviously out of the question. On top of that, the emails sent by the application also use some of these assets (the fonts in particular), so they have to remain available at the same URLs.
Reprise did offer (in 0.7) a copy option to replace Encore’s copyFiles(), but it systematically hashed the names of the copied files, with no opt-out. Encore let you choose your pattern (images/[path][name].[ext]?[hash:8] in our case: stable path on disk, hash in a query string). Our initial answer fit in a forty-line Vite plugin that reproduced Encore’s contract:
// vite-plugin-copy-stable.ts (excerpt)
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);
// The file keeps its logical path…
this.emitFile({ type: 'asset', fileName: logicalName, source });
// … and the hash goes into the manifest value, as a query string
manifestEntries[keyPrefix + logicalName] = `${publicPath}${logicalName}?${hash}`;
}
// then merge manifestEntries into the manifest.json emitted by Reprise
}
The result: not a single template modified (apart from the 4 base layouts), the JS and CSS benefit from Vite’s native hashing through entrypoints.json, and everything else keeps its stable paths.
This need felt universal enough to propose it upstream: symfony/reprise#81 adds a per-entry hash: false option to copy, which reproduces exactly that contract. It was merged and released in Reprise 0.8 a few days later: our forty lines of plugin gave way to a single line of config, with strictly identical output trees and manifests.
Astuce
Before estimating a bundler migration, take an inventory of who consumes your assets and through which channel (manifest, hardcoded paths, disk reads, CDN). That’s where the real workload hides, not in the config.
Section intitulée the-webpack-isms-that-only-show-up-at-runtimeThe webpack-isms that only show up at runtime
Once the build was green, we thought we were in the clear. But the CI was waiting for us, with a lot of failing Behat scenarios. All the problems I’m about to list share the same trait: the build passes, the typecheck passes, and yet the site no longer works at runtime.
Section intitulée code-global-code-doesn-t-existglobal doesn’t exist
global.Translator = Translator;
Webpack silently aliases global to window. Vite doesn’t:
Uncaught ReferenceError: global is not defined
The error happens at the module top level, so the whole bundle dies: not a single line of JS runs on the site any more. The nastiest part: since @types/node is installed, global is perfectly typed and tsc doesn’t flinch. The fix is trivial (window.Translator = …), but you still need to know those assignments exist.
Astuce
Search for global. in your code before migrating, it will save you from discovering the problem in the CI like we did.
Section intitulée dynamic-code-require-code-callsDynamic require() calls
<ReactSVG src={require(`../../../images/icons/${path}`)} />
This pattern relies on Webpack’s context modules, which embedded the whole icons/ folder to resolve the expression at runtime. Vite doesn’t implement them:
Uncaught ReferenceError: require is not defined
The exception blows up on the first render of a component with an icon, and React reacts by unmounting the entire tree: completely blank result pages, without a single message for the user. Since our icons were already copied at stable paths (see above), a direct URL was enough: src={/build/images/icons/${path}}.
Section intitulée the-css-code-url-code-s-that-don-t-followThe CSS url()s that don’t follow
With @tailwindcss/postcss, CSS @imports are inlined without rebasing relative paths. A url('../../fonts/brand-400.woff2') written in an imported file ends up as-is in the final CSS, resolves from the root and returns a 404: webfonts and background images gone.
The official @tailwindcss/vite plugin rewrites those same URLs to the emitted asset (url(/build/brand-400-Dm0XPNJo.woff2)), on top of being faster. That’s the integration Tailwind recommends when you build with Vite. URL rebasing on the PostCSS side has indeed been fixed several times upstream (see issue #16636, closed since), but a file imported from our own code still replayed the problem for us in 4.3.3. I can only recommend that you stop going through PostCSS if you’re using Tailwind v4 with Vite.
Section intitulée the-commonjs-vendor-fileThe CommonJS vendor file
import Routing from '../../../vendor/friendsofsymfony/jsrouting-bundle/Resources/public/js/router.min.js';
This one is devious: it works in build mode (Rollup’s commonjs plugin handles the interop), but crashes in dev only:
The requested module '…/router.min.js' does not provide an export named 'default'
Vite only prebundles node_modules, so it serves the CJS file from vendor/ as-is to a browser expecting an ES module. The solution is the fos-router npm package, which is exactly the same as the one shipped in the Symfony bundle. As a bonus, the npm package is written in TypeScript: tsc immediately flushed out a dozen window.location = url that had been lying dormant for years (since the vendor module was any, everything coming out of it escaped typing).
Astuce
These four failures share one trait: they leave no trace on the server side. The page returns a 200, the Symfony logs are empty, and the symptom only exists in the browser console: a pageerror, a console.error, or a failed asset request.
If you have e2e tests, hook Playwright’s pageerror, console and requestfailed listeners into them, if only for the duration of the migration: that’s the net that turns those silent bugs into red tests, instead of making you discover them by eye, page by page.
Section intitulée when-the-site-depends-on-a-bundler-bugWhen the site depends on a bundler bug
Here’s my favourite anecdote from this migration. After the switch to Vite, a visual regression test stubbornly refused to pass: on one variant of the site, the logo was displayed 60% too big.
We checked everything: the copied SVG file, identical; the CSS rules, identical and in the same order; the HTML, identical. As a last resort, the production Webpack manifest, which told a funny story:
"build/images/logo-pro.svg": "/build/images/logo-pro.e42c1969.svg"
A hash in the file name, where all the other copied entries used a query string. And the content of that hashed file was not the requested file: it was another SVG with the same name, located in an icons/ subfolder, with a different CSS class, hence a different size.
The explanation: the images emitted by file-loader (including the entire icons/ folder, pulled in by the dynamic require() seen above) were overwriting the manifest keys of copied files bearing the same name. For years, the site had been serving the wrong file in that spot, and that accidental rendering had become the reference, all the way into our screenshot baselines. Our cleaner Vite build was finally serving the right file… and therefore breaking the test.
We audited the project’s 31 name collisions (only one other was visible) and pointed the templates at the right file, explicitly. What I take away from this story is that a bundler is above all a resolution system: changing it reveals every accidental resolution your site depends on without you knowing.
Section intitulée one-pixel-of-differenceOne pixel of difference
Still on the visual regression side: three baselines moved by exactly 1 pixel after the migration. A centred button whose total width (icon sized in em + text) falls half a pixel differently. The cause is the change of CSS minifier: cssnano (configured with calc: false precisely to avoid that kind of rounding) gave way to esbuild.
0.01% of the pixels, invisible to the eye, but perfectly reproducible. Vite’s rendering is deterministic down to the pixel from one run to the next: we compared captures taken two days apart, zero pixel of difference.
A corollary that applies to everyone doing screenshot tests: never regenerate your baselines on the dev server. Unminified CSS produces the same rounding discrepancies there, compared to the built rendering your CI compares against, and you’ll spend a long time wondering why “it passes locally”. On our side, the screenshot update task rebuilds automatically before capturing.
Section intitulée wiring-hmr-into-the-docker-stackWiring HMR into the Docker stack
HMR (Hot Module Replacement) is the most visible gain of the migration for developers, and it deserves some attention. A small confession first: with Encore, our “watch” merely wrote files to disk, and the real dev server was designed to run inside Docker for Linux PCs, and on the host machine (so outside Docker) for Macs (because the ones from that era suffered too much). With Vite, we wanted HMR inside the stack, like everything else, and for everyone.
Section intitulée the-problems-we-ran-intoThe problems we ran into
We had three problems to solve.
Reaching the server from the browser. We first over-engineered it: a dedicated Traefik route, TLS, service discovery. Then we settled on a far simpler solution, already adopted on another one of our projects: publish the container port on the host and serve at http://localhost:5173. Browsers treat localhost as a secure origin, so no mixed content from an HTTPS page and no certificate to manage.
The lifecycle. Our first setup ran Vite in an ephemeral compose run container. Bad idea: a killed watch leaves behind a zombie that squats port 5173 and keeps overwriting files on the sly. So we defined a dedicated Compose service instead: up always reuses or recreates the same container, which makes the zombie impossible.
# docker-compose.dev.yml (excerpt)
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"
The until node_modules isn’t decorative: on the stack’s first start, Docker starts the service before yarn install has run, and you don’t want a service in a crash loop.
The container pitfalls. Two classics worth knowing. Vite’s watcher crawls the whole project by default: with a vendor/ holding 100,000 files, the inotify limit blows up. So you have to exclude it explicitly. And beware of your exclusion patterns: our **/var/**, meant for the Symfony cache, matched /var/www, the container’s working directory. The whole project was ignored by the watcher, so HMR was inoperative: the server runs, the page loads, and nothing updates. Anchor your patterns to the project directory:
watch: {
ignored: ['vendor', 'var', 'web'].map((dir) => path.resolve(__dirname, dir, '**')),
},
One last refinement, consistency between modes: our build tasks stop the dev server if it’s running (otherwise the pages go back to the built assets while an orphan server keeps running for nothing), and the watch task starts it. That way you can’t end up in an in-between state without knowing.
Section intitulée what-about-worktreesWhat about worktrees?
With AI gradually becoming unavoidable when it comes to gaining efficiency, we work more and more with git worktrees, each with its own complete, isolated Docker stack. That’s a native feature of our docker-starter template: the Compose project name is suffixed with the worktree, and all host ports are shifted automatically, which lets several stacks run in parallel.
The Vite server port simply joins that mechanism: each worktree has its own, and two developments running in parallel each have their own HMR.
One detail was left to sort out: Symfony generates absolute asset URLs from the configured base_urls, which know nothing about the shifted port. Inside a worktree, pages were therefore fetching their assets from the main checkout’s stack, and it took us a while to figure out why. Our solution: a configurable port suffix in the base_urls, empty by default (and in production):
# config/packages/framework.yaml
framework:
assets:
base_urls:
- 'https://%http.domain.front%%http.public_port_suffix%'
And rather than asking every developer to fill it in by hand, the Castor 🦫 task that starts the stack detects the worktree and syncs the value into a local parameters_override.yaml file (gitignored, meant for personal config):
Info
Our project still uses parameters.yaml files, we haven’t migrated to environment variables and the associated .env files.
// Excerpt from the task, called by `castor up`
$line = \sprintf("http.public_port_suffix: ':%d'", get_worktree_ports(get_worktree_name())['https']);
// … created or updated in parameters_override.yaml, without touching the other keys
A new worktree is thus usable with a single command, HMR included.
Section intitulée what-we-lost-along-the-wayWhat we lost along the way
For the sake of completeness, here’s what the migration cost us:
- svgo minification of the copied images was dropped, to be redone at the source if the weight becomes an issue;
- ts-loader ran a static code analysis on every build, but Vite doesn’t. So we added a new step in the CI that runs a
yarn tsc --noEmitto fill the gap (don’t forget it, it’s a real safety net that disappears otherwise); - Reprise was experimental at the time of the migration, with an API liable to move from one version to the next. That point has sorted itself out since: 1.0 has been released and adopts Symfony’s backward compatibility promise. Our upgrade from 0.8 boiled down to changing the version constraint, without a single line of code to touch.
Section intitulée a-migration-largely-delegated-to-aiA migration largely delegated to AI
One last point that may interest you: we let an AI do the bulk of this migration. Not the decision to migrate, nor the structuring choices (the asset contract, the way HMR was wired), but most of the mechanical work and, above all, the iteration on the problems we ran into.
What made that possible isn’t the AI itself, it’s the safety net that already existed around the project: a complete CI with Behat, PHPUnit and our e2e Playwright tests with screenshots. Every webpack-ism from the previous chapter was caught by a test, not by a human: the 19 red Behat scenarios for the phantom global, the screenshots for the oversized logo or the one-pixel difference, the font 404s in the captures. Every time, the AI could read the report, reproduce the problem locally, fix it, and restart the CI, without us having to step in other than to validate the choices.
Without that test coverage, the same migration would have required a visual review of dozens of pages on every iteration, and we probably wouldn’t have dared to delegate that much. It’s a good argument, if you were short of one, for investing in visual regression tests before taking on this kind of undertaking.
Section intitulée conclusionConclusion
We’ve seen in this article that the mechanical migration from Encore to Reprise takes a few hours, and that the real work happens elsewhere: in the implicit contract your code base has with its bundler, and in the handful of webpack-isms that only reveal themselves at runtime. That’s where you should look if you have to estimate such a migration.
The result is worth it: our build is five to six times faster, we’re back on node’s default config with 1 GB of heap (instead of the 4 GB previously required), we removed 583 npm packages, the config has been divided by three, and developers finally have hot reload with React fast refresh. As a bonus, it also means deployment is more than a minute faster, which is appreciable!
Being an early adopter of an experimental bundle also has its upsides: the main point of friction we ran into ended up as an upstream contribution, merged and released within a few days. The next team migrating a site with frozen asset paths will have a config option where we initially wrote a local plugin.
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
Dans le cadre d’une refonte complète de son architecture Web, le journal en ligne Mediapart a sollicité l’expertise de JoliCode afin d’accompagner ses équipes. Mediapart.fr est un des rares journaux 100% en ligne qui n’appartient qu’à ses lecteurs qui amène un fort traffic authentifiés et donc difficilement cachable. Pour effectuer cette migration, …
Afin de poursuivre son déploiement sur le Web, Arte a souhaité être accompagné dans le développement de son API REST “OPA” (API destinée à exposer les programmes et le catalogue vidéo de la chaine). En collaboration avec l’équipe technique Arte, JoliCode a mené un travail spécifique à l’amélioration des performances et de la fiabilité de l’API. Ces…
JoliCode accompagne l’équipe technique Dayuse dans l’optimisation des performances de sa plateforme. Nous sommes intervenus sur différents sujets : La fonctionnalité de recherche d’hôtels, en remplaçant MongoDB et Algolia par Redis et Elasticsearch. La mise en place d’un workflow de réservation, la migration d’un site en Twig vers une SPA à base de…