Part of Real-World CoreUI Migration: From Laravel 8 to Laravel 11

Why Migrate?

Laravel 11 dropped official Laravel Mix support. While Mix still works, the framework's asset tooling, documentation, and @vite() Blade directive assume Vite. The migration brings:

  • Faster dev builds: Vite uses native ES modules — no bundling during development
  • Hot Module Replacement: Instant CSS/JS updates without full page reload
  • Tree-shaking: ships code actually used
  • Simpler configuration: No Webpack knowledge required

Package.json Transformation

Laravel 8 (Mix):

{
    "private": true,
    "scripts": {
        "dev": "npm run development",
        "development": "mix",
        "watch": "mix watch",
        "watch-poll": "mix watch -- --watch-options-poll=1000",
        "hot": "mix watch --hot",
        "prod": "npm run production",
        "production": "mix --production"
    },
    "devDependencies": {
        "axios": "^0.21",
        "laravel-mix": "^6.0",
        "lodash": "^4.17.19",
        "sass": "^1.32",
        "sass-loader": "^11.0"
    },
    "dependencies": {
        "@coreui/coreui": "^3.4.0",
        "bs-custom-file-input": "^1.3.4"
    }
}

Laravel 11 (Vite):

{
    "private": true,
    "type": "module",
    "scripts": {
        "dev": "vite",
        "build": "vite build"
    },
    "devDependencies": {
        "axios": "^1.7.4",
        "laravel-vite-plugin": "^1.0",
        "sass-embedded": "^1.93.2",
        "vite": "^5.0",
        "vite-plugin-static-copy": "^3.2.0"
    },
    "dependencies": {
        "@coreui/coreui": "^5.1.2",
        "bs-custom-file-input": "^1.3.4"
    }
}

Key changes:

  • "type": "module" — required for Vite's ESM-based configuration
  • laravel-mixlaravel-vite-plugin + vite
  • sass + sass-loadersass-embedded (Vite handles compilation natively, no loader needed)
  • vite-plugin-static-copy — added for copying TinyMCE assets that can't be bundled
  • Six build scripts → two: npm run dev and npm run build
  • Removed: lodash (no longer a default dependency in Laravel 11)

Configuration File: webpack.mix.js → vite.config.js

Laravel 8 — webpack.mix.js:

const mix = require('laravel-mix');

mix.js('resources/js/app.js', 'public/js')
   .sass('resources/sass/app.scss', 'public/css')
   .options({
       processCssUrls: false
   });

Laravel 11 — vite.config.js:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import path from 'path';

export default defineConfig({
    plugins: [
        laravel({
            input: [
                'resources/js/admin.js',
                'resources/sass/frontend.scss',
                'resources/sass/extend.scss',
                'resources/js/tinymce-config.js'
            ],
            refresh: true,  // Auto-reload on Blade/route changes
        }),
        // TinyMCE loads plugins dynamically — can't be bundled by Vite.
        // Copy the entire tinymce directory to the build output.
        viteStaticCopy({
            targets: [
                {
                    src: 'resources/tinymce/**/*',
                    dest: 'tinymce'
                }
            ]
        })
    ],
    css: {
        preprocessorOptions: {
            scss: {
                api: 'modern-compiler',     // Use modern sass-embedded API
                additionalData: ``,
                silenceDeprecations: [      // Suppress known CoreUI SCSS warnings
                    'legacy-js-api',
                    'import',
                    'global-builtin',
                    'color-functions'
                ],
                loadPaths: ['node_modules', 'node_modules/@coreui']
            }
        }
    },
    resolve: {
        alias: {
            // Preserve the `~` import prefix used in CoreUI admin SCSS
            '~': path.resolve(__dirname, 'node_modules')
        }
    }
});

Critical Vite SCSS Configuration Details

The silenceDeprecations Problem

CoreUI 5's SCSS source still uses deprecated Sass features. Without silencing these, you get hundreds of warnings during compilation:

DEPRECATION WARNING: Using / for division is deprecated.
DEPRECATION WARNING: Global built-in functions are deprecated.
DEPRECATION WARNING: color.lighten() is deprecated. Use color.adjust() instead.

The silenceDeprecations array suppresses these without modifying CoreUI's source:

scss: {
    silenceDeprecations: [
        'legacy-js-api',     // Old Sass JS API usage
        'import',            // @import vs @use
        'global-builtin',    // Global math.div(), etc.
        'color-functions'    // color.lighten(), etc.
    ]
}

The ~ Alias for Backward Compatibility

The admin SCSS files use ~@coreui/coreui/scss/... import syntax (common with Webpack/Mix). Vite doesn't understand ~ by default, so we add an alias:

resolve: {
    alias: {
        '~': path.resolve(__dirname, 'node_modules')
    }
}

This allows the admin SCSS (which uses the full CoreUI import) to work unchanged:

// admin/_custom-coreui.scss — these ~-prefixed imports work via the Vite alias
@import "~@coreui/coreui/scss/functions";
@import "~@coreui/coreui/scss/variables";
@import "~@coreui/coreui/scss/mixins";

While the frontend SCSS (which was rewritten) uses explicit relative paths:

// default/_frontend-coreui.scss — explicit paths, no alias needed
@import "../../../node_modules/@coreui/coreui/scss/functions";
@import "../../../node_modules/@coreui/coreui/scss/variables";

Blade Template Updates: mix()@vite()

Laravel 8 (Mix) — admin layout:

<head>
  <!-- Mix generates versioned assets in public/js and public/css -->
  <script src="{{ mix('js/app.js') }}" defer></script>
  <link rel="stylesheet" href="{{ mix('css/app.css') }}">
</head>

Laravel 11 (Vite) — admin layout:

<head>
  <!-- Vite generates assets in public/build/ with manifest.json -->
  @vite(['resources/js/admin.js'])

  <!-- Legacy pre-built CoreUI 3 JS bundle — NOT processed by Vite -->
  <script src="{{ asset('js/app.js') }}" defer></script>

  <!-- Static assets still use asset() -->
  <link rel="stylesheet" href="{{ asset('css/admin.css') }}">
  <link rel="stylesheet" href="{{ asset('css/fontawesome.css') }}">
  @stack('styles')
</head>
<!-- ... -->
<body>
  <!-- TinyMCE loaded from Vite's static copy output -->
  <script src="{{ asset('build/tinymce/tinymce.min.js') }}"></script>
  @vite(['resources/js/tinymce-config.js'])
  @stack('scripts')
</body>

Laravel 11 (Vite) — frontend layout:

<head>
  <!-- Legacy CoreUI 3 JS bundle for dropdown/navbar interactivity -->
  <script src="{{ asset('js/app.js') }}" defer></script>
  @stack('scripts')

  <!-- Vite-compiled SCSS (CoreUI 5 selective imports + custom styles) -->
  @vite(['resources/sass/frontend.scss', 'resources/sass/extend.scss'])
  <link rel="stylesheet" href="{{ asset('css/fontawesome.css') }}">
  @stack('styles')
</head>

Key differences explained:

  1. mix('js/app.js')@vite(['resources/js/admin.js']) — Vite references source files, not output paths
  2. mix('css/app.css')@vite(['resources/sass/frontend.scss']) — SCSS files referenced directly; Vite compiles them
  3. Multiple @vite() calls are valid — each generates both <script> and <link> tags as needed
  4. The @vite() directive automatically handles dev server URLs vs production manifest

The Dual-Asset Strategy

This project runs a hybrid approach — not everything goes through Vite:

public/
├── build/              ← Vite output (npm run build)
│   ├── assets/
│   │   ├── admin-{hash}.js
│   │   ├── frontend-{hash}.css
│   │   ├── extend-{hash}.css
│   │   └── tinymce-config-{hash}.js
│   ├── tinymce/        ← Static copy (not bundled)
│   └── manifest.json
├── css/
│   ├── admin.css       ← Pre-built CoreUI 3 admin stylesheet
│   ├── fontawesome.css ← Static (not processed by Vite)
│   └── prism.css       ← Static syntax highlighting
├── js/
│   ├── app.js          ← Pre-built CoreUI 3 JS bundle (Bootstrap 4 JS)
│   └── prism.js        ← Static syntax highlighting

Why not put everything through Vite?

The admin panel's public/js/app.js is a pre-built CoreUI 3 JavaScript bundle containing Bootstrap 4 JS components (dropdowns, modals, tooltips) and Perfect Scrollbar. Migrating this to Vite would require rewriting all admin Blade templates to use Bootstrap 5 data-bs-* attributes and CoreUI 5 JavaScript initialization. The cost/benefit didn't justify it — the admin panel works, the legacy bundle is ~200KB gzipped, and it can be migrated to CoreUI 5 JavaScript independently later.

SCSS Entry Point Architecture

The project uses separate SCSS entry points for frontend and admin, compiled through Vite:

resources/sass/
├── frontend.scss           ← Vite entry: @import 'default/app'
├── extend.scss             ← Vite entry: custom utility classes
├── app-bag.scss            ← Legacy (pre-Vite era, not used)
├── fontawesome.scss        ← Compiled separately, not via Vite
│
├── default/                ← Frontend theme
│   ├── app.scss            ← Main: imports variables → coreui → components
│   ├── _variables.scss     ← Theme colors ($primary, $bg-main, etc.)
│   ├── _custom-coreui.scss ← Toggle: full CoreUI vs selective imports
│   ├── _frontend-coreui.scss  ← Selective CoreUI 5 imports (7-step order)
│   ├── _components.scss    ← Custom component barrel file
│   └── components/
│       ├── _card.scss
│       ├── _article.scss
│       ├── _layout.scss              ← Bootstrap 4→5 compatibility layer
│       ├── _navigation-overrides.scss ← CoreUI CSS variable overrides
│       ├── _other.scss
│       └── media.scss
│
└── admin/
    └── _custom-coreui.scss ← Full CoreUI import (uses ~ alias)

The key design decision: Frontend pages load the CoreUI 5 modules they actually use (navbar, dropdown, badge, buttons, etc.) for minimal CSS. The admin panel loads the full CoreUI set because it uses every component.

Development Workflow Comparison

Laravel 8 (Mix):

npm run watch-poll          # Recompiles on file changes (polling)
# ↑ Full Webpack rebuild on every change: ~3-8 seconds
# Output: public/js/app.js, public/css/app.css

Laravel 11 (Vite):

npm run dev                 # Starts Vite dev server on localhost:5173
# ↑ No bundling during development — serves ES modules directly
# CSS changes: instant (<50ms)
# JS changes: HMR, no page reload needed

npm run build               # Production build with minification + hashing
# Output: public/build/assets/* with manifest.json

Production Build & Deployment

# Build production assets
npm run build

# Laravel reads public/build/manifest.json to resolve @vite() paths:
# {
#   "resources/js/admin.js": {
#     "file": "assets/admin-BkH4x7q2.js",
#     "src": "resources/js/admin.js"
#   },
#   "resources/sass/frontend.scss": {
#     "file": "assets/frontend-D3nK9p1m.css",
#     "src": "resources/sass/frontend.scss"
#   }
# }

The @vite() directive automatically resolves source paths to hashed production filenames via the manifest. No cache-busting configuration needed — it's built into Vite's output.