Vite Patterns (Laravel)
Asset bundling and dev-server patterns for Laravel apps using the official
laravel-vite-plugin. Covers vite.config.js, the @vite Blade directive,
HMR in development, env vars, aliases, manifests, production builds, code
splitting, and bundling for Livewire / Filament / Alpine.
Constraints
- Apply
@rules/laravel/laravel.mdc - Apply
@rules/laravel/livewire.mdcand@rules/laravel/filament.mdcwhen bundling assets for those layers - Apply
@rules/php/core-standards.mdcfor any PHP touched (Blade config exposure, service providers) - This stack uses
laravel-vite-pluginonly. Never introduce React/Vue plugins, SSR frameworks, library mode, Bun, or Next.js. - Secrets never go into
VITE_-prefixed vars — those are inlined into the public bundle. - Keep examples to
npm,php artisan serve, andnpm run dev/npm run build.
Use when
- Setting up or editing
vite.config.jswith thelaravel()plugin. - Wiring entrypoints and the
@vite([...])directive into Blade layouts. - Getting HMR / hot reload working in local development.
- Exposing config to the client via
VITE_env vars or Blade-side config. - Adding
resolve.aliaspaths, code splitting, or dynamic imports. - Understanding the manifest, cache-busting, and the production build.
- Bundling JS/CSS that Livewire, Filament, or Alpine depend on.
- Building assets in CI before deploy.
How it works
- Dev mode (
npm run dev) runs a Vite dev server that serves source files as native ESM and pushes HMR updates. Thelaravel-vite-pluginwrites apublic/hotfile; the@vitedirective detects it and points<script>/<link>tags at the dev server instead of built files. - Build mode (
npm run build) bundles, hashes, and writes assets topublic/build/plus amanifest.json.@vitereads the manifest and emits the hashed URLs. Cache-busting is automatic via the content hash in filenames. - Env vars prefixed
VITE_are statically inlined into the client bundle viaimport.meta.env. Everything else stays server-side.
vite.config.js — the laravel() plugin
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/css/app.css',
'resources/js/app.js',
],
refresh: true, // full-page reload on Blade/route/PHP changes
}),
],
});
inputlists every entrypoint. Add more for admin panels or per-section bundles (resources/js/admin.js).refresh: truetriggers a full reload when Blade views, routes, or PHP config change. Pass an array of globs to watch extra paths:
laravel({
input: ['resources/js/app.js'],
refresh: ['resources/views/**', 'app/Livewire/**'],
}),
The @vite Blade directive
Load entrypoints in your layout <head>:
{{-- resources/views/layouts/app.blade.php --}}
<!DOCTYPE html>
<html>
<head>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
{{ $slot }}
</body>
</html>
- No
@viteReactRefreshis needed — this is a Blade/Livewire/Alpine stack, not React. Do not add it. - In dev,
@viteemits a script pointing at the running dev server. In production it resolves hashed URLs from the manifest. Same directive, both modes — you write it once. - For assets referenced from JS (images, fonts), import them so Vite fingerprints
them; for Blade-referenced static assets use
Vite::asset('resources/...').
@vite + Tailwind
Tailwind compiles through the CSS entrypoint, so no extra Vite wiring is needed:
/* resources/css/app.css */
@import "tailwindcss";
// resources/js/app.js
import './bootstrap';
@vite(['resources/css/app.css', ...]) handles HMR for Tailwind classes in dev
and outputs a hashed, purged stylesheet in the production build.
HMR / hot reload in development
Run two processes:
php artisan serve # serves the Laravel app
npm run dev # Vite dev server + HMR
- The dev server writes
public/hot. Addpublic/hotandpublic/buildto.gitignore. - Edits to JS/CSS hot-swap without a full reload; with
refreshenabled, Blade/PHP edits trigger a full-page reload. - Behind a custom domain or container, expose the host and the HMR port:
laravel({ input: ['resources/js/app.js'], refresh: true }),
// server config:
server: {
host: '0.0.0.0',
hmr: { host: 'localhost' },
},
Environment variables
Only VITE_-prefixed vars reach the client bundle via import.meta.env:
// resources/js/app.js
const apiUrl = import.meta.env.VITE_API_URL;
const mode = import.meta.env.MODE; // 'development' | 'production'
# .env
VITE_API_URL="${APP_URL}/api"
VITE_is not a security boundary — these values are inlined into the shipped JS. Put only public values (public URLs, feature flags, public keys) here. API tokens, DB credentials, and signing keys stay server-side.- For values the client needs but that depend on per-request state, prefer passing them from Blade instead of baking them at build time:
<script>
window.AppConfig = @json(['locale' => app()->getLocale(), 'csrf' => csrf_token()]);
</script>
Aliases (resolve.alias)
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
plugins: [laravel({ input: ['resources/js/app.js'], refresh: true })],
resolve: {
alias: {
'@': fileURLToPath(new URL('./resources/js', import.meta.url)),
},
},
});
Then import Foo from '@/components/Foo';. Keep the alias list small — add an
entry only when a real import path needs it.
Manifest & production build
npm run build
- Outputs hashed files to
public/build/assets/pluspublic/build/manifest.json. @vitereads the manifest to emit the correct hashed URLs — no manual versioning. The content hash in each filename is the cache-busting mechanism; changed files get new hashes, unchanged files keep theirs so browsers reuse cached copies.- Commit neither
public/buildnorpublic/hot; build assets in deploy/CI.
Code splitting & dynamic import
Vite splits dynamically imported modules into separate chunks automatically:
// load a heavy module only when needed
button.addEventListener('click', async () => {
const { renderChart } = await import('./chart.js');
renderChart(data);
});
Group stable vendor code into its own chunk to improve cache reuse across deploys:
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['alpinejs', 'axios'],
},
},
},
},
Avoid splitting every dependency into its own chunk — that produces many tiny requests. Group by stability instead.
Prefetching
For routes/modules likely needed soon, hint the browser with a dynamic import behind an idle callback so the chunk is fetched ahead of interaction:
requestIdleCallback?.(() => import('./chart.js'));
This warms the chunk cache without blocking the initial render.
Bundling for Livewire / Filament / Alpine
- Alpine: register it from your entrypoint and start it once.
// resources/js/app.js
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
- Livewire: Livewire ships its own JS; keep your
@vitebundle additive (custom Alpine components, hooks) and let Livewire manage its own assets per@rules/laravel/livewire.mdc. Do not bundle a second Alpine copy — Livewire already includes one; if you import Alpine yourself, follow Livewire's guidance to avoid a duplicate instance. - Filament: Filament publishes and serves its own compiled assets; use a
Filament theme + its asset pipeline for panel styling rather than forcing it
through your app entrypoint (
@rules/laravel/filament.mdc). Reserve your Vite bundle for front-end (non-panel) views.
Building for production in CI
npm ci
npm run build # writes public/build + manifest.json
Run npm run build in CI before deploying; ship public/build/. Missing
manifest entries surface at render time as a Vite manifest not found
exception, so the build step must succeed before the app boots in production.
Done when
vite.config.jsdeclares every entrypoint via thelaravel()plugin withrefreshconfigured for the watched paths.- Layouts load assets through
@vite([...]); no@viteReactRefreshpresent. npm run dev+php artisan servegive working HMR locally;public/hotandpublic/buildare gitignored.- Only
VITE_-prefixed (public) vars are inlined client-side; secrets stay server-side. npm run buildproduces a hashedpublic/build/+manifest.json, and CI runs the build before deploy.- Livewire/Filament keep their own asset pipelines; Alpine is started exactly once.