knpuniversity/webpack-encore-bundle
Symfony bundle that integrates Webpack Encore, adding Twig helpers for script and link tags, entrypoint management, and asset versioning so you can use modern JavaScript tooling cleanly within Symfony applications.
This package is a Symfony bundle, not a Laravel library — there is no bundle system in Laravel, no Twig by default, and the knpuniversity/* namespace is archived (superseded by symfony/webpack-encore-bundle). So "day-to-day leverage" splits into two realities:
If you're actually on a legacy Symfony app: don't install this namespace — install the maintained successor, same API, drop-in:
composer require symfony/webpack-encore-bundle
yarn add --dev @symfony/webpack-encore
// config/bundles.php
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
# config/packages/webpack_encore.yaml
webpack_encore:
output_path: '%kernel.project_dir%/public/build'
script_attributes: { defer: true }
If you're on Laravel (the common case in this repo's context): do not install the package. Harvest the pattern — reading entrypoints.json + manifest.json in a service provider and exposing Blade directives — because that's the entire value of the bundle (~150 lines of JSON reading). The bundle's job in Symfony is the same job laravel-vite-plugin already does for Vite.
Where to look first:
webpack.config.js — your entry names (Encore.addEntry(...)).public/build/entrypoints.json — what the bundle actually consumes; every JS/CSS/preload path originates here.public/build/manifest.json — hashed names for files imported from templates or PHP (e.g. images, fonts).EncoreExtension.php in the bundle source to see exactly which YAML keys are honored (there aren't many).First use case (Laravel side): a single app entry, one Blade layout using @encoreScripts('app') and @encoreLinks('app'), one controller — verify end-to-end before adding entries.
The Laravel-side equivalent (harvest the pattern). Register a small service provider and Blade directives that mirror encore_entry_script_tags / encore_entry_link_tags:
// app/Providers/EncoreServiceProvider.php
namespace App\Providers;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider;
class EncoreServiceProvider extends ServiceProvider
{
public function boot(): void
{
Blade::directive('encoreScripts', fn ($e) => "<?php echo app(\\App\\Support\\Encore::class)->scripts($e); ?>");
Blade::directive('encoreLinks', fn ($e) => "<?php echo app(\\App\\Support\\Encore::class)->links($e); ?>");
Blade::directive('encoreAsset', fn ($e) => "<?php echo app(\\App\\Support\\Encore::class)->asset($e); ?>");
}
}
// app/Support/Encore.php
namespace App\Support;
use RuntimeException;
class Encore
{
private ?array $entrypoints = null;
private ?array $manifest = null;
public function __construct(
private string $build = 'build',
private ?string $devServer = null, // e.g. 'http://localhost:8080'
) {}
public function scripts(string $entry, array $attrs = ['defer' => true]): string
{
if ($this->devServer) {
return '<script src="' . $this->devServer . "/{$entry}.js\" type=\"module\"></script>";
}
$attrStr = collect($attrs)->map(
fn ($v, $k) => ' ' . ($v === true ? $k : "$k=\"$v\"")
)->implode('');
return collect($this->entrypoints()[$entry]['js'] ?? [])
->map(fn ($src) => "<script src=\"" . asset($this->build . "/$src") . "\"{$attrStr}></script>")
->implode("\n");
}
public function links(string $entry): string
{
if ($this->devServer) {
return ''; // css injected by the dev server
}
return collect($this->entrypoints()[$entry]['css'] ?? [])
->map(fn ($src) => '<link rel="stylesheet" href="' . asset($this->build . "/$src") . '">')
->implode("\n");
}
public function asset(string $logical): string
{
$this->manifest ??= json_decode(
file_get_contents(public_path("{$this->build}/manifest.json")), true
)['build'] ?? [];
return asset($this->build . '/' . ($this->manifest[$logical] ?? $logical));
}
private function entrypoints(): array
{
if ($this->entrypoints !== null) {
return $this->entrypoints;
}
$path = public_path("{$this->build}/entrypoints.json");
if (! is_file($path)) {
throw new RuntimeException("Encore entrypoints.json missing at {$path} — run `yarn encore dev` or `yarn encore production`.");
}
return $this->entrypoints = json_decode(file_get_contents($path), true)['entrypoints'] ?? [];
}
}
Bind it with $this->app->singleton(Encore::class, fn () => new Encore(devServer: config('encore.dev_server')));.
Entry naming discipline. The string passed to @encoreScripts must match an Encore.addEntry(key, ...) exactly. Split by page area, not by file: app, admin, checkout. Dynamic import()s inside an entry get emitted as separate chunks and pulled in automatically — no extra tags.
Per-page entries, shared runtime. The Symfony-side pattern that carries over verbatim:
Encore
.addEntry('app', './assets/app.js')
.addEntry('admin', './assets/admin.js')
.splitEntryChunks()
.enableSingleRuntimeChunk();
{{-- layouts/base.blade.php --}}
@encoreScripts('app')
@stack('page_scripts')
{{-- admin/index.blade.php --}}
@push('page_scripts')
@encoreScripts('admin')
@endpush
enableSingleRuntimeChunk() is what makes multi-entry pages safe: without it, two encore_entry_script_tags calls on one page emit the runtime twice and your app breaks with Uncaught TypeError: webpack is not defined (or duplicate module execution).
Resolving assets from controllers/services. In Symfony you'd inject EncoredPaths; Laravel-side you inject App\Support\Encore and call $encore->asset('images/logo.png'). The rule of thumb: use entry tags in templates, use asset() in PHP, never build paths by hand — the hash is the only thing keeping a stale-cache deploy from serving mismatched JS and CSS.
Preload/prefetch. Encore emits preload and prefetch arrays alongside js/css in entrypoints.json. If you port the pattern, emit these explicitly — Symfony's bundle does:
foreach ($this->entrypoints()[$entry]['preload'] ?? [] as $src) {
$out .= '<link rel="preload" href="' . asset($this->build . "/$src") . '" as="script">';
}
Skipping this doesn't break anything at first, but it silently regresses TTI on every multi-chunk page.
Dev-server vs. prod switching. The Symfony bundle's webpack_encore.dev_server YAML key is the whole mechanism. In Laravel, do the same: a single config value (config('encore.dev_server')) that flips between "read entrypoints.json" and "point at localhost:8080". Do not branch on app()->environment() — local dev with a production build is a real workflow and you'll want it to work.
knpuniversity/webpack-encore-bundle. It is archived, 10 stars, 0 dependents, superseded by symfony/webpack-encore-bundle (same API, same Twig functions). If you find it in a legacy composer.json, the migration is composer remove knpuniversity/webpack-encore-bundle && composer require symfony/webpack-encore-bundle with zero code changes.symfony/framework-bundle, which pins Symfony component versions. On Laravel this can block composer update of symfony/http-kernel and friends on the next Laravel minor bump — a "blocked upgrade," not an asset bug. The failure presents in the worst possible place.entrypoints.json produces empty tag output, not an exception, in the Symfony bundle's default behavior. Your ported version should throw (as the example above does). If you're on the Symfony bundle and see blank <script> output in staging, check the file exists and that output_path points at the right directory — this is the #1 support thread on the repo.entrypoints.json and manifest.json must be deployed together. A CDN or opcache serving yesterday's HTML with today's hashed chunks yields 404s on the JS and, worse, 404s only on lazy-loaded chunks. Deploy public/build/ atomically (release directories + symlink), not by rsync.entrypoints.json still exists but the bundle ignores it in favor of the dev server URL. Handrolled ports frequently get this half-right — CSS ends up requested from /build/... while JS comes from localhost:8080. Decide once, in one config value.enableSingleRuntimeChunk() vs. enableSingleRuntimeChunk({ async: false }) — the async variant changes filenames. Pick one and don't flip it mid-project; every cached entrypoints.json will disagree with every cached template.WebpackEncoreEvents:: events and exposes EncoredPaths for programmatic resolution. In a Laravel port, the equivalent is a singleton Encore with setBuildDir() / setDevServer() so tests and per-package builds can override it without touching config/*.php.entrypoints.json contents, not on rendered HTML — the tags include asset() which is APP_URL-dependent and will vary between local and CI. A single test that loads the real fixture manifest catches 80% of port regressions.laravel-vite-plugin gives you the same entrypoints.json-equivalent, first-party HMR, and no symfony/framework-bundle in your tree. The port only justifies itself when the migration cost of an existing webpack.config.js (custom loaders, plugins) exceeds the cost of owning ~150 lines of manifest glue forever. Be honest about which side of that line you're on — most teams aren't.How can I help you explore Laravel packages today?