Symfony Reprise
EXPERIMENTAL This bundle is experimental and is likely to change, or even change drastically.
Webpack Encore gave Symfony first-class asset integration for Webpack. Symfony Reprise brings the same to Vite and Rsbuild.
Tip
Still using Webpack Encore? See Migrating from Webpack Encore below: it maps every Encore.* call to its
Vite/Rsbuild or Reprise equivalent.
Vite and Rsbuild already handle Sass/Less/PostCSS, TypeScript, JSX/Vue/Svelte, code splitting, content hashing, source maps, minification and HMR on their own, so Symfony Reprise does not reimplement any of that. It covers only the Symfony-side glue the bundlers leave out:
- Multiple entries: build several independent entry points from one config
entrypoints.json: generated in both build and dev-server modesmanifest.json: maps each logical filename to its hashed URL- Asset versioning: content-hash cache busting, wired into the manifest
- File copy: copy static files into the build, keyed in the manifest
- Dev server and HMR: points Twig at the running Vite/Rsbuild server
- Multiple builds: drive several bundles (e.g. a main app and a separately-built embeddable widget) from one Symfony app
- Twig tag rendering:
reprise_entry_script_tags/reprise_entry_link_tagsrender straight fromentrypoints.json - Symfony UX / Stimulus: registers
controllers.jsonand local controllers, eager or lazy - CDN support: serve built assets from an absolute
publicPath - Subresource Integrity: SRI hashes in
entrypoints.json
It generates the Encore-compatible entrypoints.json and manifest.json that RepriseBundle reads to render
the <script> and <link> tags, wires up the native dev server, and turns your Stimulus controllers into a
running application.
Installation
Install the bundle with Composer and Symfony Flex:
1
$ composer require symfony/reprise
Then install the npm package:
1
$ npm install @symfony/reprise --save-dev
Vite
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// vite.config.ts
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'
export default defineConfig({
build: {
// On Vite 7 or older, use `rollupOptions` instead
rolldownOptions: {
input: {
app: './assets/app.js',
},
},
},
plugins: [
Symfony({
// options
}),
],
})
Rsbuild
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'
export default defineConfig({
source: {
entry: {
app: './assets/app.js',
},
},
plugins: [
Symfony({
// options
}),
],
})
Rendering asset tags
This is where Reprise pays off: once entrypoints.json exists, RepriseBundle reads it and renders the
<script> and <link> tags for an entry directly in Twig, the same experience WebpackEncoreBundle gives you
with encore_entry_script_tags and encore_entry_link_tags.
Five Twig functions come with it:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
{# templates/base.html.twig #}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
{% block stylesheets %}
{{ reprise_entry_link_tags('app') }}
{% endblock %}
{% block javascripts %}
{# reprise_entry_script_tags renders <script type="module"> (ESM output from both Vite and Rsbuild) #}
{{ reprise_entry_script_tags('app') }}
{% endblock %}
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
reprise_entry_js_files('app') and reprise_entry_css_files('app') are the same lookup, minus the HTML: they
return the raw URL lists, for the rare case where you need the paths rather than the tags.
reprise_entry_exists('checkout') returns true when the named entry is present in entrypoints.json and
false otherwise. Use it to guard a page-specific or optional entry so rendering its tags doesn't error in strict
mode:
1 2 3
{% if reprise_entry_exists('checkout') %}
{{ reprise_entry_script_tags('checkout') }}
{% endif %}
If you're migrating from Webpack Encore, it's a tag-for-tag swap (Reprise is Encore's heritage, so the template shape hasn't changed):
:header-rows: 1
- Webpack Encore
- Symfony Reprise
- Output
encore_entry_link_tags('app')reprise_entry_link_tags('app')<link rel="stylesheet">tags
encore_entry_script_tags('app')reprise_entry_script_tags('app')<script type="module">tags
encore_entry_css_files('app')reprise_entry_css_files('app')- CSS URL list
encore_entry_js_files('app')reprise_entry_js_files('app')- JS URL list
encore_entry_exists('app')reprise_entry_exists('app')true/false
reprise_entry_script_tags and reprise_entry_link_tags take a fourth attributes argument, merged over the
global script_attributes/link_attributes (per-call wins, false drops the attribute). It mirrors Encore's
fourth argument; since it follows the optional package name, pass it as a Twig named argument:
1 2 3
{# a fourth `attributes` argument, merged over the configured script_attributes/link_attributes #}
{{ reprise_entry_script_tags('app', attributes={ 'data-turbo-track': 'reload' }) }}
{{ reprise_entry_link_tags('app', attributes={ media: 'print' }) }}
Nothing to set up for the common case. The tags resolve against Symfony's default asset package, so a standard
project needs nothing beyond installing the bundle (see Configuration below for the options). The snippet is the
same whether Vite or Rsbuild produced entrypoints.json, and there's nothing to configure for dev either: in dev
reprise_entry_script_tags injects the Vite HMR client automatically, while under Rsbuild the client is compiled
into the bundle.
Features
The rest of what Reprise carries over from Encore is opt-in. Turn on each feature through the plugin options in your
vite.config.ts or rsbuild.config.ts, and leave out the ones you don't need.
Symfony UX / Stimulus controllers
This is the Vite/Rsbuild counterpart of what @symfony/stimulus-bridge did for Webpack Encore: it turns your
controllers.json into a Stimulus application, with the same enable step, same helper, same local-controllers
convention.
Enable it by pointing the plugin at your controllers.json (this is what turns the feature on):
1 2 3 4 5 6 7 8 9 10
Symfony({
stimulus: 'assets/controllers.json',
})
// or, to override the local controllers dir:
Symfony({
stimulus: {
controllersJson: 'assets/controllers.json',
controllersDir: 'assets/controllers',
},
})
Then start the app from your entry:
1 2 3
import { startStimulusApp } from '@symfony/reprise/stimulus'
const app = startStimulusApp()
Local controllers. Any assets/controllers/*_controller.{js,ts} is registered automatically. The filename
becomes the identifier (hello_controller.js becomes hello, admin/user_controller.js becomes
admin--user). To load a controller on demand, put a stimulusFetch: 'lazy' comment directly above the
class; a block or a single-line comment both work.
1 2 3 4
import { Controller } from '@hotwired/stimulus'
/* stimulusFetch: 'lazy' */
export default class extends Controller {}
(// stimulusFetch: 'lazy' on the line above the class works too, as does a preserved
/*! stimulusFetch: 'lazy' */ comment: the form tsc and esbuild keep through minification.)
Third-party UX packages. Controllers declared in controllers.json are resolved from node_modules, so
install them with your package manager, the same as you would with Webpack Encore. For example, with Stimulus and
UX Leaflet Map:
1
$ npm install @hotwired/stimulus @symfony/ux-leaflet-map
Some packages need a bit of bundler-specific setup on top, the same way they did under Webpack Encore. UX Leaflet Map, for instance, ships a CSS file meant for Webpack's loader and needs an alias to the plain CSS build:
1 2 3 4 5 6 7 8 9
// vite.config.ts
export default defineConfig({
// ...
resolve: {
alias: {
'leaflet/dist/leaflet.min.css': 'leaflet/dist/leaflet.css',
},
},
})
Check each package's own docs for this kind of tweak.
File copy
Some assets aren't imported from JavaScript or CSS at all: they're referenced by a stable path straight from your
templates, like {{ asset('build/images/logo.svg') }}. Point copy at the directories that hold them and
Reprise copies each file into the build and records it in manifest.json. Once Symfony is pointed at that
manifest (below), the asset() helper resolves the logical path to the hashed URL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// vite.config.ts
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'
export default defineConfig({
// ...
plugins: [
Symfony({
copy: [
{
from: 'assets/images',
to: 'images',
},
],
}),
],
})
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'
export default defineConfig({
// ...
plugins: [
Symfony({
copy: [
{
from: 'assets/images',
to: 'images',
},
],
}),
],
})
from and to are both required: from is the source directory (relative to your project root), to is
the destination prefix used for the manifest key. Restrict which files are copied with pattern, a regular
expression tested against each file's path relative to from (by default every file is copied).
includeSubdirectories defaults to true; set it to false to turn off recursion.
How copied files are handled depends on the mode:
- Build: each file gets a content hash in its filename for cache busting.
- Dev: files are copied verbatim, no hash.
Either way they land in public/build and are served by the Symfony web server, not the Vite/Rsbuild dev server,
so they're available whether or not the dev server is running.
For asset() to return the hashed URL, point Symfony's asset component at the generated manifest.json with
framework.assets.json_manifest_path:
1 2 3 4
# config/packages/framework.yaml
framework:
assets:
json_manifest_path: '%kernel.project_dir%/public/build/manifest.json'
This is Symfony's native manifest support, the same setting Webpack Encore relies on, so it applies to every logical
asset reference, not just copied files. The entry references that RepriseBundle renders already carry their hash
and aren't manifest keys, so they pass through untouched.
By default the lookup is lenient: a reference missing from the manifest is returned unchanged. Set
framework.assets.strict_mode: true to fail loudly on an unknown reference instead. Under strict mode, the entry
references RepriseBundle renders would be rejected too, so route them through a package that skips the manifest:
set reprise.asset_package to a package with version: false (see Configuration) and keep
json_manifest_path on the default package for your other assets.
Using a CDN
To serve your built assets from a CDN, set publicPath to the absolute CDN URL, for the production build only. In
dev, the dev server serves assets directly, so keep the local /build/ path there. Both bundlers expose the mode
through the function form of their config, so switch on command === 'build':
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// vite.config.ts (command is 'serve' or 'build')
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'
export default defineConfig(({ command }) => ({
// ...
plugins: [
Symfony({
publicPath:
command === 'build'
? 'https://my-cool-app.com.global.prod.fastly.net/build/'
: '/build/',
manifestKeyPrefix: 'build/',
}),
],
}))
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// rsbuild.config.ts (command is 'dev' or 'build')
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'
export default defineConfig(({ command }) => ({
// ...
plugins: [
Symfony({
publicPath:
command === 'build'
? 'https://my-cool-app.com.global.prod.fastly.net/build/'
: '/build/',
manifestKeyPrefix: 'build/',
}),
],
}))
With an absolute publicPath, manifestKeyPrefix is required: Reprise has no way to guess the right prefix
for the manifest.json keys, and throws a clear error if it's missing. Keys stay logical, values point at the
CDN:
1 2 3
{
"build/app.js": "https://my-cool-app.com.global.prod.fastly.net/build/app-1a2b3c.js"
}
entrypoints.json is rewritten the same way, so the <script> and <link> tags render with CDN URLs. You
still have to upload the built files to the CDN yourself, or set up origin pull. For a CDN subdirectory, include it
in the URL (https://my-cool-app.com.global.prod.fastly.net/awesome-website/build/).
Subresource Integrity
When enabled, Reprise adds an integrity map to entrypoints.json (asset URL -> SRI hash). RepriseBundle
reads that map and renders integrity="..." on the generated <script> and <link> tags, so the browser
refuses any asset whose bytes were tampered with.
The integrity option takes an object { enabled, algorithms? }. It only makes sense for the production build:
the dev server serves changing in-memory assets, so no hashes are emitted in dev. As with the CDN example, toggle it
with command === 'build':
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// vite.config.ts (command is 'serve' or 'build')
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'
export default defineConfig(({ command }) => ({
// ...
plugins: [
Symfony({
integrity: {
enabled: command === 'build',
algorithms: ['sha384'],
},
}),
],
}))
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// rsbuild.config.ts (command is 'dev' or 'build')
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'
export default defineConfig(({ command }) => ({
// ...
plugins: [
Symfony({
integrity: {
enabled: command === 'build',
algorithms: ['sha384'],
},
}),
],
}))
algorithms is optional and defaults to ['sha384']. Accepted values are 'sha256', 'sha384' and
'sha512'. Passing several (e.g. ['sha256', 'sha512']) writes multiple space-separated hashes per file, which
the browser treats as "any one of these must match".
The resulting entrypoints.json gets an extra integrity section:
1 2 3 4 5 6
{
"integrity": {
"/build/app-1a2b3c.js": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K...",
"/build/app-4d5e6f.css": "sha384-9ehJ4G8v3aQ2p1o0..."
}
}
Hashes cover every referenced file in each entry (js, css, and preloaded/dynamic chunks), and since they're computed from the files actually written to disk, they stay correct through minification and hashing.
Multiple builds
Several areas of one app (a public part, an admin panel) are usually just separate entry points in one config: the
Multiple entries case, addressed by name without a build argument. Reach for multiple builds only when a part
needs its own bundler config and output directory, like an embeddable widget built apart from the main app.
Give the widget its own config, pointing the plugin at a separate output directory -> with Vite:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// vite.config.widget.ts -- run with `vite build --config vite.config.widget.ts`
import { defineConfig } from 'vite'
import Symfony from '@symfony/reprise/vite'
export default defineConfig({
build: {
rolldownOptions: {
input: {
widget: './assets/widget.js',
},
},
},
plugins: [
Symfony({
outputPath: 'public/widget-build',
publicPath: '/widget-build/',
}),
],
})
or with Rsbuild:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// rsbuild.config.widget.ts -- run with `rsbuild build --config rsbuild.config.widget.ts`
import { defineConfig } from '@rsbuild/core'
import Symfony from '@symfony/reprise/rsbuild'
export default defineConfig({
source: {
entry: {
widget: './assets/widget.js',
},
},
plugins: [
Symfony({
outputPath: 'public/widget-build',
publicPath: '/widget-build/',
}),
],
})
These two blocks are alternatives: pick the one that matches your bundler, you don't use both in one project.
Name that directory in reprise.yaml:
1 2 3 4 5 6 7
# config/packages/reprise.yaml
reprise:
# the main app (your existing config), addressed without a build name
output_path: '%kernel.project_dir%/public/build'
builds:
# each extra build: name -> the directory its entrypoints.json lives in
widget: '%kernel.project_dir%/public/widget-build'
In Twig, pass the build name to load from it; omit it for the default build:
1 2 3 4 5 6 7
{# the main app (default build) #}
{{ reprise_entry_script_tags('app') }}
{{ reprise_entry_link_tags('app') }}
{# the widget build, selected with the build argument #}
{{ reprise_entry_script_tags('widget', build='widget') }}
{{ reprise_entry_link_tags('widget', build='widget') }}
The build argument works on every reprise_entry_* function. Each build has its own dev server, so the app's
and the widget's can run at once on different ports, each injecting its HMR client once. Set output_path: false
for a named-builds-only setup (at least one build is required).
Configuration
Reprise exposes a few optional settings under its own configuration, all shown here at their default value:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
# config/packages/reprise.yaml
reprise:
# Directory the @symfony/reprise plugin writes entrypoints.json and manifest.json into.
# Set to false when using only named builds (requires at least one entry under builds).
output_path: '%kernel.project_dir%/public/build'
# Additional named builds: a map of build name -> output directory (see `Multiple builds`_).
builds: {}
# Throw when entrypoints.json or a requested entry is missing, instead of rendering nothing.
strict_mode: true
# Cache the parsed entrypoints.json in a compiled PHP file, warmed at cache:warmup (needs symfony/cache).
cache: false
# A framework.assets package name used to resolve entry URLs. null uses the default package.
asset_package: null
# crossorigin attribute set alongside SRI integrity: false, 'anonymous' or 'use-credentials'.
crossorigin: false
# Register rendered assets as WebLink HTTP/2 Link: preload headers (needs symfony/web-link).
preload: true
# Default attributes added to every rendered <script> / <link> tag.
script_attributes: []
link_attributes: []
output_path: filesystem directory holdingentrypoints.jsonandmanifest.json. Must match the plugin's ownoutputPath. Acceptsfalseto disable the default build entirely (requires at least one entry underbuilds).builds: a map of build name -> output directory for additional bundles. Each named build is addressed by passingbuild='<name>'to the tag and file functions. See Multiple builds.strict_mode: whentrue(the default), throws a clear exception on a missing file or an unknown entry; whenfalse, renders nothing instead.cache: whentrue, parseentrypoints.jsononce atcache:warmupand read it from a compiled PHP file at runtime instead of decoding the JSON on every request. Enable it in production and runcache:clearafter rebuilding your assets; it needssymfony/cache(composer require symfony/cache).asset_package: resolve entry URLs through a specificframework.assetspackage instead of the default one. You only need this if your default package applies a version strategy, which would re-hash files Reprise already content-hashed and break the URLs. Point it at a package withversion: false(see below).crossorigin:false,'anonymous'or'use-credentials'(any other value is a configuration error), applied together with SRI integrity (see Subresource Integrity).preload: emit WebLink HTTP/2Link:preload headers when symfony/web-link is installed;falseto disable.script_attributes/link_attributes: maps of default attributes added to every generated tag, e.g.defer: trueordata-turbo-track: reload.
If you set asset_package, define that package with version: false:
1 2 3 4 5 6 7 8
reprise:
asset_package: reprise
framework:
assets:
packages:
reprise:
version: false
Migrating from Webpack Encore
Reprise is Webpack Encore's successor for Vite and Rsbuild, so the move splits in two. The Symfony side (the bundle
and your Twig templates) barely changes. Your webpack.config.js mostly goes away: Vite and Rsbuild do natively
what most Encore.* calls set up, so you delete those calls rather than translate them. What is left maps to a
Reprise plugin option or a bundler plugin.
The Symfony side
Reprise ships its own RepriseBundle and does not use WebpackEncoreBundle, so swap the Composer package:
1 2
$ composer remove symfony/webpack-encore-bundle
$ composer require symfony/reprise
In your templates the Twig functions keep the same shape, with the encore_ prefix becoming reprise_ (the full
list is under Rendering asset tags):
1 2 3 4 5 6 7
{# before #}
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}
{# after #}
{{ reprise_entry_link_tags('app') }}
{{ reprise_entry_script_tags('app') }}
The bundle config carries over almost key-for-key: webpack_encore.output_path becomes reprise.output_path,
and crossorigin, preload, cache, strict_mode, script_attributes and link_attributes all exist
under reprise with the same meaning. Encore's builds option maps to Multiple builds.
Your build config
Most of webpack.config.js has no equivalent, because the bundler already does the work. The Symfony glue Encore
layered on top of Webpack stays, as a Reprise plugin option:
:header-rows: 1
- Webpack Encore
- Reprise
setOutputPath()/setPublicPath()- plugin
outputPath/publicPath(the defaults fit a standard project)
addEntry()/addEntries()- the bundler's own entry input: Vite
build.rollupOptions.input, Rsbuildsource.entry
enableVersioning()- nothing to do, content hashing is on by default
enableIntegrityHashes()- plugin
integrity: { enabled: true }(see Subresource Integrity)
copyFiles()- plugin
copy: [ ... ](see File copy)
enableStimulusBridge()- plugin
stimulus: 'assets/controllers.json'(see Symfony UX / Stimulus controllers)
configureDevServerOptions()- nothing to do: run
viteorrsbuild devand Reprise points Twig at it
Everything Vite and Rsbuild handle themselves, you drop:
:header-rows: 1
- Webpack Encore
- Now handled by the bundler
enableSassLoader()/enableLessLoader()/enableStylusLoader()- install the preprocessor and import the file (Vite out of the box, Rsbuild via
@rsbuild/plugin-sassand friends)
enablePostCssLoader()- add a
postcss.config.js, picked up automatically
enableTypeScriptLoader()/configureBabel()/configureBabelPresetEnv()- native transpilation (Vite via esbuild, Rsbuild via SWC); set targets with
browserslistorbuild.target
splitEntryChunks()/configureSplitChunks()- native code splitting
enableSourceMaps()- native (Vite
build.sourcemap)
configureImageRule()/configureFontRule()/configureFilenames()- native asset handling and output naming
addAliases()resolve.alias
addExternals()- Vite
build.rollupOptions.external, Rsbuildoutput.externals
cleanupOutputBeforeBuild()- native (Vite
build.emptyOutDir, Rsbuild cleans by default)
Framework presets become the bundler's own plugin:
:header-rows: 1
- Webpack Encore
- Vite
- Rsbuild
enableReactPreset()@vitejs/plugin-react@rsbuild/plugin-react
enableVueLoader()@vitejs/plugin-vue@rsbuild/plugin-vue
enablePreactPreset()@preact/preset-vite@rsbuild/plugin-preact
enableSvelte()@sveltejs/vite-plugin-svelte@rsbuild/plugin-svelte
A few Encore features have no direct replacement:
autoProvideVariables()/autoProvidejQuery(): prefer importing what you use. To inject a global anyway, use@rollup/plugin-injectunder Vite orrspack.ProvidePlugin(through Rsbuild'stools.rspack).enableBuildNotifications()and the ESLint integration are gone: run your linter as its own script, outside the build.Encore.isProduction()/isDev()/when(): branch on the bundler mode instead, e.g.defineConfig(({ command }) => ...)wherecommandis'build'or the dev command.