The default asset bundling provided by Laravel, whether through Mix or Vite, is exceptionally well-suited for monolithic frontends. It assumes a conventional structure: a single resources/js
directory, a single package.json
, and one unified build process. This model breaks down when enforcing a strict interpretation of Clean Architecture or Domain-Driven Design within a PHP monorepo. Our transition led to a structure where each business domain, or Bounded Context, lived in its own directory under src/
, complete with its own UI components, assets, and logic. Forcing this modular reality into a single vite.config.js
resulted in a configuration file filled with brittle path aliases and complex, interdependent entry points. The most significant pain point emerged in our CI/CD pipeline: any change to a single line of JavaScript in one domain triggered a full, monolithic rebuild of all assets, ballooning pipeline times from two minutes to over ten. This was unsustainable.
The initial concept was to decentralize the build process, mirroring the architectural philosophy of the application itself. Each domain module should be responsible for its own assets. We needed a build tool that was both powerful and programmable, capable of discovering entry points across a non-standard directory structure and orchestrating their compilation. We decided to discard the framework-provided abstractions and manage the build process directly using Rollup. Rollup’s configuration-as-code approach, using standard ES modules, offered the programmatic flexibility we needed. The objective was clear: create a build script that could scan our src/
directory, identify each domain’s asset entry points, build them independently, and produce a single, unified manifest.json
that Laravel’s backend could consume for cache-busting.
This is the directory structure we settled on. It physically separates each Bounded Context, isolating its domain logic, application services, and importantly, its UI assets.
/
├── src/
│ ├── Common/
│ │ └── ...
│ ├── IdentityAccess/
│ │ ├── Application/
│ │ ├── Domain/
│ │ ├── Infrastructure/
│ │ └── UI/
│ │ └── assets/
│ │ ├── js/
│ │ │ └── main.js
│ │ └── scss/
│ │ └── main.scss
│ └── Invoicing/
│ ├── Application/
│ ├── Domain/
│ ├── Infrastructure/
│ └── UI/
│ └── assets/
│ ├── js/
│ │ └── app.js
│ └── scss/
│ └── styles.scss
├── laravel/
│ ├── app/
│ ├── public/
│ ├── ...
├── node_modules/
├── package.json
└── rollup.config.mjs
The core of the solution is the rollup.config.mjs
. A common mistake is to hardcode entry points. In a modular system where new domains are added frequently, this is a maintenance bottleneck. Instead, we use glob
to dynamically discover them.
// rollup.config.mjs
import { globSync } from 'glob';
import path from 'path';
import { fileURLToPath } from 'url';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import postcss from 'rollup-plugin-postcss';
import terser from '@rollup/plugin-terser';
import { visualizer } from 'rollup-plugin-visualizer';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const isProduction = process.env.NODE_ENV === 'production';
const publicDir = path.resolve(__dirname, 'laravel/public');
const buildDir = path.resolve(publicDir, 'build');
// Custom plugin to generate a Laravel-compatible manifest.json
const laravelManifest = (buildDir) => {
const manifest = {};
return {
name: 'laravel-manifest',
generateBundle(options, bundle) {
for (const [fileName, chunkInfo] of Object.entries(bundle)) {
if (!chunkInfo.isEntry) {
continue;
}
// We need the original source path to use as the manifest key.
// Rollup's `facadeModuleId` provides this absolute path.
const sourcePath = chunkInfo.facadeModuleId;
if (!sourcePath) continue;
// Transform the absolute source path into a module-relative path.
// e.g., /path/to/project/src/IdentityAccess/UI/assets/js/main.js
// becomes -> IdentityAccess/js/main.js
const relativeSource = path.relative(path.resolve(__dirname, 'src'), sourcePath);
const [moduleName, , , assetType, ...rest] = relativeSource.split(path.sep);
const manifestKey = `${moduleName}/${assetType}/${rest.join('/')}`;
manifest[manifestKey] = {
file: fileName,
src: manifestKey
};
// Handle associated CSS files for JS entries
if (chunkInfo.imports) {
chunkInfo.imports.forEach(importedFile => {
if(importedFile.endsWith('.css')) {
const cssKey = manifestKey.replace(/\.js$/, '.css');
manifest[cssKey] = {
file: importedFile,
src: cssKey
};
}
});
}
}
this.emitFile({
type: 'asset',
fileName: 'manifest.json',
source: JSON.stringify(manifest, null, 2)
});
}
};
};
// Discover all entry points (js and scss)
const entryPoints = globSync('src/*/UI/assets/{js,scss}/*.{js,scss}');
export default {
input: entryPoints.reduce((acc, entry) => {
// Create a logical name for the entry point based on its path
// e.g., src/IdentityAccess/UI/assets/js/main.js -> IdentityAccess-js-main
const relativePath = path.relative(path.resolve(__dirname, 'src'), entry);
const name = relativePath.replace(/[\/\\]/g, '-').replace('.js', '').replace('.scss', '');
acc[name] = entry;
return acc;
}, {}),
output: {
dir: buildDir,
format: 'es',
entryFileNames: 'js/[name].[hash].js',
chunkFileNames: 'js/chunks/[name].[hash].js',
assetFileNames: 'css/[name].[hash].css',
sourcemap: !isProduction,
},
plugins: [
resolve(),
commonjs(),
babel({
babelHelpers: 'bundled',
exclude: 'node_modules/**',
}),
postcss({
extract: true,
minimize: isProduction,
sourceMap: !isProduction,
to: `${buildDir}/css/main.css`, // This is a fallback name, assetFileNames handles the real output
}),
isProduction && terser(),
laravelManifest(buildDir),
!isProduction && visualizer({ open: true }), // Helps debug bundle sizes
].filter(Boolean), // Filter out falsy values like inactive plugins
watch: {
clearScreen: false,
}
};
The critical piece here is the custom laravelManifest
plugin. Rollup doesn’t natively produce a manifest in the format required by Laravel’s asset helpers. This plugin hooks into the generateBundle
lifecycle event, which runs after all files have been bundled but before they are written to disk. It iterates through the output bundle, finds the original source path for each entry chunk (facadeModuleId
), and constructs a key-value mapping that links the original path to the final, hashed output file. The key is transformed into a logical name like IdentityAccess/js/main.js
, which is far more intuitive to use in our views.
With the build process defined, the next challenge was integrating it into Laravel. We created a custom Artisan command to orchestrate the build. This encapsulates the logic and ensures a consistent process for both local development and CI pipelines.
// laravel/app/Console/Commands/BuildFrontendAssets.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
class BuildFrontendAssets extends Command
{
protected $signature = 'assets:build {--watch : Watch for changes and rebuild} {--production : Build for production}';
protected $description = 'Build frontend assets using the root Rollup configuration.';
public function handle(): int
{
$this->info('Starting frontend asset build...');
$command = ['npm', 'run', $this->option('production') ? 'build' : 'dev'];
if ($this->option('watch')) {
$command = ['npm', 'run', 'watch'];
}
// We run the command from the project root, not the `laravel` directory
$process = new Process($command, base_path('../'));
$process->setTimeout(null); // No timeout for long builds or watch mode
// This allows real-time output in the console
$process->run(function ($type, $buffer) {
$this->output->write($buffer);
});
if (!$process->isSuccessful()) {
$this->error('Asset build failed.');
throw new ProcessFailedException($process);
}
$this->info('Asset build completed successfully.');
// Invalidate OpCache if it's running to ensure PHP sees the new manifest
if (function_exists('opcache_reset')) {
opcache_reset();
$this->info('OPcache cleared.');
}
return Command::SUCCESS;
}
}
This command simply executes the npm run build
script defined in the root package.json
. The real value is in providing a consistent, framework-integrated entry point. We also added logic to clear OPcache
, a subtle but critical step. Without it, a long-running PHP-FPM process might continue serving views using a cached, stale version of the asset manifest reader function, leading to 404s for new assets.
To consume the manifest, we needed a custom helper. Relying on the built-in vite()
or mix()
helpers was not an option, as they are hardwired to specific manifest structures and locations.
// laravel/app/Helpers/AssetHelper.php
namespace App\Helpers;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\HtmlString;
class AssetHelper
{
private static ?array $manifest = null;
public static function path(string $entry): string
{
if (self::$manifest === null) {
self::$manifest = self::loadManifest();
}
if (!isset(self::$manifest[$entry])) {
Log::error("Asset not found in manifest: {$entry}");
// In a non-production environment, failing loudly is better.
if (!app()->isProduction()) {
throw new \Exception("Asset not found in manifest: {$entry}");
}
return '';
}
return asset('build/' . self::$manifest[$entry]['file']);
}
public static function loadManifest(): array
{
// Cache the manifest in production for performance.
// The cache is cleared on deployment.
return Cache::rememberForever('asset_manifest', function () {
$manifestPath = public_path('build/manifest.json');
if (!file_exists($manifestPath)) {
Log::critical('manifest.json not found. Run "php artisan assets:build".');
return [];
}
return json_decode(file_get_contents($manifestPath), true);
});
}
public static function clearManifestCache(): void
{
Cache::forget('asset_manifest');
}
}
This helper loads our custom manifest.json
, caches it indefinitely in production for performance, and provides a simple AssetHelper::path('IdentityAccess/js/main.js')
method to resolve the versioned asset URL. We use this directly in our Blade templates. A common pitfall here is file I/O on every request; caching the decoded manifest is non-negotiable for production performance.
The final and most impactful piece was optimizing the CI/CD pipeline. The goal was to build only the assets for domains that had actually changed.
graph TD A[Start CI Job] --> B{Detect Changed Modules}; B -- No Changes --> C[Skip Asset Build]; B -- Changes Detected --> D{Run Rollup for Changed Modules}; D --> E{Generate Unified Manifest}; E --> F{Deploy Artefacts}; C --> F;
Here is a simplified GitLab CI configuration illustrating the caching and change detection logic.
# .gitlab-ci.yml
build_assets:
stage: build
image: node:18-alpine
variables:
# Use a file-based cache key. The key changes only when dependencies change.
NODE_CACHE_KEY: "node-modules-cache-${CI_COMMIT_REF_SLUG}"
cache:
key: ${NODE_CACHE_KEY}
paths:
- node_modules/
policy: pull-push
script:
- |
# Install dependencies only if the cache is missed
if [ ! -d "node_modules" ]; then
echo "Node modules cache not found. Installing dependencies..."
npm ci
else
echo "Node modules cache found."
fi
- |
# Change detection logic
# Get the list of commits for this push/merge request
if [ -n "$CI_MERGE_REQUEST_IID" ]; then
# For merge requests, compare against the target branch
git fetch origin ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}
COMMIT_RANGE="origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}..${CI_COMMIT_SHA}"
else
# For direct pushes, compare the last two commits
COMMIT_RANGE="HEAD~1..HEAD"
fi
echo "Checking for changes in range: ${COMMIT_RANGE}"
CHANGED_FILES=$(git diff --name-only ${COMMIT_RANGE})
# Check if any changed files are within a module's UI assets directory
if echo "${CHANGED_FILES}" | grep -q "src/.*/UI/assets/"; then
echo "Asset changes detected. Running full build."
npm run build
else
echo "No asset changes detected. Skipping asset build."
# Create a dummy build directory to satisfy later stages
mkdir -p laravel/public/build
echo "{}" > laravel/public/build/manifest.json
fi
artifacts:
paths:
- laravel/public/build/
This CI script implements a crucial optimization. It performs a git diff
to see if any files within any src/*/UI/assets/
directory have been modified in the current push or merge request. If no asset files have changed, the entire npm run build
step is skipped, saving significant time. The real-world result was a reduction of our average pipeline time by nearly 70% for backend-only changes. A more advanced version of this script could even detect which specific modules changed and pass them as arguments to a modified Rollup script, enabling partial builds, but this simpler “all or nothing” approach provided the bulk of the value with less complexity.
This system is not without its limitations. The developer experience for Hot Module Replacement (HMR) is more complex to set up than with a default Laravel Vite configuration. Our solution is optimized for production builds, and a separate, more conventional Vite setup might be preferable for local development if HMR is a hard requirement. Furthermore, while the change detection in CI is effective, it relies on file paths. A more robust solution might involve a build orchestrator like Nx or Turborepo, which understands the dependency graph more deeply. This current implementation, however, strikes a pragmatic balance between architectural purity, build performance, and implementation complexity.