/ Content
If you're deploying a Laravel app to a single server, you probably never think about manifest.json. You run npm run build, Vite compiles your assets, and everything works. But the moment you add a second web server, things get weird. Assets go missing, pages render without CSS, and you're staring at a console full of 404s wondering what happened.
We ran into this exact problem when scaling to multiple web servers behind a load balancer with assets stored on S3. The fix ended up being a custom Vite class that caches the manifest in Redis. But before I get to the solution, it helps to understand what the manifest actually is and why it matters.
What is manifest.json
When you run npm run build, Vite compiles and bundles all your JavaScript and CSS files. As part of that process, it generates a manifest.json file in your public/build/ directory. This file is a map from your source file paths to their compiled, versioned output paths:
{
"resources/js/app.js": {
"file": "assets/app-BkS4axGH.js",
"name": "app",
"src": "resources/js/app.js",
"isEntry": true,
"css": [
"assets/app-D4mRkV2x.css"
]
},
"resources/css/app.css": {
"file": "assets/app-D4mRkV2x.css",
"src": "resources/css/app.css",
"isEntry": true
}
}
Those hashes in the filenames (BkS4axGH, D4mRkV2x) are the important part. Vite generates them based on the file contents. If the file changes, the hash changes, and browsers fetch the new version instead of serving a stale cached copy. If the file doesn't change between builds, the hash stays the same. This is cache busting, and it's the reason your users see your latest CSS changes without having to clear their browser cache.
How Laravel uses it
When you use the @vite directive in your Blade templates:
@vite(['resources/js/app.js', 'resources/css/app.css'])
Laravel's Vite class reads manifest.json, looks up the entry for resources/js/app.js, and renders the proper HTML tags with the versioned paths:
<script type="module" src="/build/assets/app-BkS4axGH.js"></script>
<link rel="stylesheet" href="/build/assets/app-D4mRkV2x.css">
Under the hood, the Vite class has a manifest() method that loads and caches the manifest file into a static array on first access. Every subsequent call in the same request just reads from that array. The manifest only gets loaded once per request, which is fine for a single server where the file is always right there on disk.
The multi-server problem
Here's where our setup got complicated. We had two web servers behind an ALB, and we moved our compiled assets to S3 (served via CloudFront) so both servers could serve the same files. The deploy process ran on both servers simultaneously.
The first problem was both servers trying to run npm run build and upload to S3 at the same time. We were getting throttling errors from AWS because two machines were uploading the same files concurrently. So we changed the deploy to only have one server build and push to S3.
That introduced the second problem. The server that didn't run the build had no manifest.json on disk. When Laravel tried to render @vite, it couldn't find the manifest and threw a Vite manifest not found exception. The compiled assets were sitting in S3 just fine, but the second server had no way to know what the versioned filenames were.
We thought about a few options. We could have the build server SCP the manifest to the other servers, but that doesn't scale and adds deployment complexity. We could have every server run npm run build locally (since the hashes are deterministic based on file content, they'd match), but then we're back to the the throttling problem with S3 uploads. We could store the manifest on S3 and fetch it from there, but hitting S3 on every page load adds latency.
The solution: Redis-cached manifest from S3
What we ended up with was a custom ViteS3 class that extends Laravel's Vite. It overrides the manifest() method to check Redis first, fall back to S3 if the cache is empty, and fall back to local disk as a last resort.
namespace App\Support;
use Illuminate\Foundation\Vite;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
class ViteS3 extends Vite
{
protected function manifest($buildDirectory)
{
$path = config('services.s3.public_path').'/'.$buildDirectory.'/manifest.json';
if (! isset(static::$manifests[$path])) {
if ($this->loadManifestFileFromCache($path)) {
return static::$manifests[$path];
}
if (! Storage::disk('s3')->exists($path)) {
return parent::manifest($buildDirectory);
}
$this->saveManifestFileToCache($path, Storage::disk('s3')->get($path));
}
return static::$manifests[$path];
}
protected function loadManifestFileFromCache($path): bool
{
$manifest = Cache::get('vite_manifest:'.$path);
if ($manifest !== null) {
static::$manifests[$path] = $manifest;
return true;
}
return false;
}
protected function saveManifestFileToCache($path, $manifest): void
{
static::$manifests[$path] = json_decode($manifest, true);
Cache::put('vite_manifest:'.$path, static::$manifests[$path], now()->addHour());
}
public static function clearManifestCache(?string $buildDirectory = null): bool
{
$buildDirectory = $buildDirectory ?? 'build';
$path = config('services.s3.public_path').'/'.$buildDirectory.'/manifest.json';
$cacheKey = 'vite_manifest:'.$path;
if (Cache::has($cacheKey)) {
Cache::forget($cacheKey);
if (isset(static::$manifests[$path])) {
unset(static::$manifests[$path]);
}
return true;
}
return false;
}
}
The lookup order is:
- Static memory (already loaded this request)
- Redis cache (shared across all servers)
- S3 (the source of truth)
- Local disk (fallback for dev or single-server setups)
The first page load after a deploy on any server hits S3 once, caches the manifest in Redis for an hour, and every subsequent request on every server reads from Redis. After that initial S3 fetch, there's zero additional latency compared to reading from disk.
Wiring it up
To swap Laravel's default Vite class for the custom one, bind it in a service provider:
use App\Support\ViteS3;
use Illuminate\Foundation\Vite;
public function register(): void
{
$this->app->singleton(Vite::class, function () {
return new ViteS3();
});
}
Since the @vite Blade directive resolves the Vite class from the container, this swap is transparent. No template changes needed.
Clearing on deploy
After a deploy, you want to bust the Redis cache so the new manifest gets picked up. We added a simple step to the deploy script:
php artisan tinker --execute "App\Support\ViteS3::clearManifestCache()"
You could also wrap this in an Artisan command if you prefer something cleaner in your deploy pipeline:
Artisan::command('vite:clear-manifest', function () {
$cleared = ViteS3::clearManifestCache();
$this->info($cleared ? 'Manifest cache cleared.' : 'No cached manifest found.');
});
The full deploy flow ends up looking like this:
- Both servers pull the latest code
- Server A runs
npm run buildand uploads assets to S3 - Server A clears the manifest cache from Redis
- Both servers restart their workers and clear app caches
- First request on either server fetches the manifest from S3, caches it in Redis
- Every subsequent request reads from Redis
Server B never needs to run npm run build. It never needs the manifest on local disk. It gets the same versioned file paths as Server A because they're both reading from the same Redis cache.
Would I do this again
Honestly, for most apps you don't need this. If you're on a single server, or even if you're using a platform like Forge or Vapor that handles asset compilation as part of the deploy pipeline, the default Vite behavior is fine. This solution exists because we had a specific set of constraints: multiple EC2 instances, S3 for static assets, and a deploy process that we controlled end to end.
If you do find yourself in a similar situation, the pattern is pretty adaptable. Swap S3 for any shared storage, swap Redis for any shared cache, and the same approach works. The key insight is that the manifest is just a JSON file that maps source paths to versioned paths. It doesn't have to live on disk.