Vite has been Laravel's first-party asset bundler since Laravel 9 (June 2022). Laravel Mix — the webpack wrapper that shipped with Laravel 5.4 through 8 — still works, but it is maintenance-only, and every current starter kit, package, and tutorial assumes Vite. If you have just upgraded an older application to Laravel 13, the build is probably the last piece of 2020 left in it.
This is the migration as we do it on client projects: mechanical where it can be, with the pitfalls that actually cost time called out.
Why move
- Speed. Vite serves unbundled ES modules in development, so startup is near-instant and hot module replacement is fast regardless of app size. Webpack rebuilds scale with the bundle.
- Ecosystem. Livewire, Inertia, Filament, Tailwind v4, and the Laravel starter kits all target Vite. Mix support in third-party packages is disappearing.
- Maintenance. The Laravel Vite plugin is actively developed (version 3 introduced a dedicated
assetsoption after changes in Vite 8); Mix is not.
Before you start
Commit a clean tree and note what Mix currently produces. webpack.mix.js is your spec — read it and list every .js(), .sass(), .postCss(), .copy(), .version(), .extract(), and .options() call. Each maps to something in the Vite setup, and .copy() is the one people forget.
Make sure your Laravel version has Vite support in the framework: that means Laravel 9.19 or newer, where the @vite Blade directive and the Vite facade live. If you are still on Laravel 8, upgrade first (see our Laravel 8 to 13 field guide).
Step 1: Swap the packages
npm remove laravel-mix
npm install --save-dev vite laravel-vite-plugin
Remove webpack.mix.js once you have translated it (keep a copy open while you work). Update package.json scripts:
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}
Any npm run watch, npm run hot, or npm run prod references in your deployment scripts or CI become npm run dev (local only) and npm run build.
Step 2: Translate webpack.mix.js to vite.config.js
A typical Mix file:
// webpack.mix.js
const mix = require('laravel-mix');
mix.js('resources/js/app.js', 'public/js')
.sass('resources/sass/app.scss', 'public/css')
.copy('resources/images', 'public/images')
.version();
Becomes:
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
export default defineConfig({
plugins: [
laravel({
input: [
'resources/sass/app.scss',
'resources/js/app.js',
],
assets: ['resources/images/**'],
refresh: true,
}),
],
});
Mapping notes:
- Entry points. Every
mix.js()/mix.sass()/mix.postCss()source becomes an item ininput. You no longer specify output paths; Vite writes versioned files intopublic/buildand a manifest that Laravel reads. - Sass. Vite compiles
.scssnatively once younpm install --save-dev sass. Keep the entry ininput, or import the stylesheet fromapp.jsand drop the CSS entry (the recommended setup for SPAs and Inertia). .version()is automatic. Vite always fingerprints production output..copy()has no direct equivalent. Use the plugin'sassetsoption (as above) and reference files withVite::asset(), or leave truly static files inpublic/and reference them as before..extract()(vendor chunk splitting) is handled by Rollup's automatic code splitting; drop it unless you have a measured reason to configurebuild.rollupOptions.output.manualChunks.- Vue or React. Add
@vitejs/plugin-vueor@vitejs/plugin-reacttoplugins. React also needs the@viteReactRefreshBlade directive before@vite. mix.options({ processCssUrls: false }). Vite rewrites URLs in CSS by default; if you relied on turning that off, setcss.urlhandling in Vite or move the referenced files underresources/so Vite can process them.
Step 3: Replace mix() in Blade
Every mix() helper call goes away in favour of one @vite directive in the <head> of your layout:
{{-- before --}}
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
<script src="{{ mix('js/app.js') }}" defer></script>
{{-- after --}}
@vite(['resources/sass/app.scss', 'resources/js/app.js'])
The paths are the source paths from input, not the old public/ output paths. In development, @vite detects the running dev server (via the public/hot file) and injects the Vite client for HMR; in production it reads public/build/manifest.json and emits versioned tags.
Search the codebase for leftovers: grep -rn "mix(" resources/views should come back empty. Mail templates and error pages are the usual stragglers.
Step 4: Environment variables
Mix exposed .env values prefixed with MIX_ as process.env.MIX_*. Vite exposes values prefixed with VITE_ as import.meta.env.VITE_*:
# .env before
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
# .env after
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
// before
key: process.env.MIX_PUSHER_APP_KEY,
// after
key: import.meta.env.VITE_PUSHER_APP_KEY,
Do this on every environment's .env at the same time, including CI and production, or the first production build silently ships undefined into your Echo config.
Step 5: CommonJS to ES modules
Vite is ES-module only. Files that use require() and module.exports need converting:
// before
window._ = require('lodash');
window.axios = require('axios');
// after
import _ from 'lodash';
import axios from 'axios';
window._ = _;
window.axios = axios;
The resources/js/bootstrap.js that older Laravel skeletons shipped is the main offender. Old jQuery plugins that expect a global jQuery at import time may need import jQuery from 'jquery'; window.jQuery = window.$ = jQuery; before the plugin import, in a separate module so ordering is guaranteed.
Common pitfalls
Static asset copying. The most common post-migration bug report is "images are 404 in production". Files that Mix copied into public/ are no longer copied. Either declare them in the plugin's assets option and switch references to {{ Vite::asset('resources/images/logo.png') }}, or move them permanently into public/ and reference them with asset(). Pick one strategy and apply it consistently.
HMR behind a proxy or in Docker. If the browser cannot reach the Vite dev server (Sail, a reverse proxy, WSL2), you get a page with no styles and console errors about localhost:5173. Configure the server and HMR host explicitly:
export default defineConfig({
plugins: [laravel({ /* ... */ })],
server: {
host: '0.0.0.0',
hmr: { host: 'localhost' },
},
});
Behind a TLS-terminating proxy, set hmr.host to the public hostname and hmr.protocol: 'wss'. Herd and Valet users get TLS detection for free via the plugin's detectTls option.
A stale public/hot file. If the dev server is killed uncleanly, public/hot stays behind and @vite keeps pointing at a dead server. Delete the file. Add it to .gitignore if it is not already there (the default skeleton ignores it).
Deploy scripts. Run npm ci && npm run build during deployment, and make sure public/build is not committed unless you deliberately build elsewhere. Forge and Laravel Cloud both run the build step for you if you leave it in the deploy script.
Tailwind. Tailwind v3 works with the postcss.config.js you already have. Tailwind v4 prefers the @tailwindcss/vite plugin and no PostCSS config at all; migrate Tailwind as a separate change after the Vite move is green.
Verify
npm run build
php artisan view:clear
Load the application with the dev server stopped and APP_ENV=production locally to confirm the manifest path works, then run your browser tests. If you have Pest browser tests from the upgrade, this is where they earn their keep.
If you would like the migration done alongside a framework upgrade, it is part of our Laravel upgrade services. Questions? Contact us.