DEV Community

Cover image for Laravel Route Caching: What Breaks It in Production
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Route Caching: What Breaks It in Production

Laravel route caching is one of the standard deploy optimizations, and one of the few that can break a site without a single error in your logs: a new page that returns 404 only in production, a deploy that fails halfway at route:cache, or routes that quietly disappear because they were built for the wrong environment. Part 1 covered how routes are structured, Part 2 covered model binding, and Part 3 covered middleware. This part covers what route caching really does and the specific ways it fails.

What route:cache actually does

On every request, Laravel normally executes your route files (web.php, api.php, and any others) to build the route table. With a large app and many packages registering routes, that is real work repeated on every request. route:cache does it once, writes the finished route table to a compiled file in bootstrap/cache/, and from then on Laravel loads that file instead of running your route files at all.

php artisan route:cache     # build the cache
php artisan route:clear     # delete it
php artisan route:list      # show the routes Laravel will actually use
php artisan optimize        # caches config, events, routes and views together
php artisan optimize:clear  # clears all of those caches
Enter fullscreen mode Exit fullscreen mode

The speedup grows with the number of routes, so a small app barely notices and an app with hundreds of routes does. The important consequence is the second half of that sentence: once a cache exists, editing a route file changes nothing until you rebuild the cache. Almost every problem below comes from that one fact.

Failure 1: the stale cache (new route returns 404)

You deploy new code with a new route. The route file is correct, the controller exists, and the URL still returns 404. The cause is that the server is serving the route table that was cached before your deploy.

This happens when the cache is built at the wrong moment: before the new code is in place, in a different build step than the one that ships, or not rebuilt at all. A reliable deploy script clears and rebuilds after the code is final and treats a failure as a failed deploy:

set -e   # stop the deploy if any command fails

composer install --no-dev --optimize-autoloader

php artisan route:clear
php artisan route:cache
Enter fullscreen mode Exit fullscreen mode

On your own machine the opposite advice applies: don't leave a route cache enabled in local development. If a new route you just wrote doesn't show up, run php artisan route:clear first, before debugging anything else.

Failure 2: duplicate route names make route:cache throw

Routes without caching tolerate two routes with the same name; the later one simply wins. Caching does not. If two routes share a name, route:cache stops with an error like Unable to prepare route [widgets] for serialization. Another route has already been assigned name [widgets.index]. Everything works locally and fails at deploy, which is the worst time to find out.

The most common cause is registering the same resource for both the web and the API:

// routes/web.php
Route::resource('widgets', WidgetController::class);       // widgets.index, widgets.show ...

// routes/api.php
Route::apiResource('widgets', Api\WidgetController::class); // widgets.index again: collision
Enter fullscreen mode Exit fullscreen mode

As Part 1 noted, routes in api.php get a /api URI prefix but no name prefix, so the names collide. Give the API routes their own name prefix:

// routes/api.php
Route::name('api.')->group(function () {
    Route::apiResource('widgets', Api\WidgetController::class); // api.widgets.index
});
Enter fullscreen mode Exit fullscreen mode

Run php artisan route:list and look for repeated names, but don't rely on it to catch this: route:list works fine with duplicates. Only route:cache fails.

Failure 3: closure routes

You will still see advice saying closure routes can't be cached. That was true of older Laravel versions and produced the error Unable to prepare route for serialization. Uses Closure. In current versions closure routes can be cached, because Laravel serializes the closure with its laravel/serializable-closure package. That fixed one problem and introduced others:

The serialized closure is signed with your application key, so if the key changes after the cache is built, the cached routes become invalid. And because the key is needed when the cache is built, you can't safely build a closure-route cache in a step that doesn't have the real key, such as a Docker image build that is deliberately kept free of secrets. Controller-based routes have neither problem, which is the strongest practical reason to move anything beyond a trivial closure into a controller.

// Fragile to cache: tied to the application key
Route::get('/status', fn () => response()->json(['ok' => true]));

// Safe to cache anywhere
Route::get('/status', [StatusController::class, 'show']);
Enter fullscreen mode Exit fullscreen mode

Failure 4: routes decided at cache time

The cache stores the route table exactly as it looked after your route files ran. Any condition inside a route file is evaluated once, when you run route:cache, and the result is frozen:

// Evaluated when route:cache runs, not on each request
if (app()->environment('local')) {
    Route::get('/debug', [DebugController::class, 'index']);
}
Enter fullscreen mode Exit fullscreen mode

If a CI job builds the cache with APP_ENV=local or testing, the debug route ships to production inside the cache even though production's own environment is production. If it's built with production, the route is missing from a staging build that expected it. Build the cache in the environment it will run in, and avoid environment checks that decide whether a route exists. Protect routes with middleware instead, which runs per request.

Debugging a "route not found" that only happens in production

Work through this in order, on the production machine or an identical container:

  1. Run php artisan route:list --path=your/url. With a cache present this shows the cached table, so if the route is missing here, the cache is stale or was built wrong.
  2. Run php artisan route:clear and request the URL again. If it now works, the cause was the cache; fix the deploy step that builds it.
  3. If you run several servers or containers, check that every one rebuilt its cache. One stale instance behind a load balancer produces 404s that appear and disappear on refresh.
  4. Confirm bootstrap/cache/ is writable by the user running the deploy, or route:cache can't write its file.

Catch it in CI instead of in production

Duplicate names and unserializable routes are both detected the moment you run route:cache, so run it in your CI pipeline on every pull request:

php artisan route:cache
php artisan route:clear
Enter fullscreen mode Exit fullscreen mode

If it fails, the pull request fails, and the duplicate-name error never reaches a deploy. Clear the cache afterwards so later test steps in the same job don't run against a frozen route table.

Quick reference

Symptom Likely cause Fix
New route 404s after deploy Cache built before the new code, or not rebuilt route:clear then route:cache after code is final
route:cache fails: "Another route has already been assigned name" Two routes share a name (often resource + apiResource) Give one set a name prefix
Cache invalid after changing the app key Closure routes are signed with the key Rebuild the cache; prefer controller routes
Debug or env-specific route in production Cache built with a different APP_ENV Build in the target environment; use middleware, not conditionals
404s that come and go behind a load balancer Some servers have a stale cache Rebuild on every instance
New local route not appearing Stale local cache php artisan route:clear

Before you ship it

Run php artisan route:cache in CI so naming and serialization errors fail the build. Rebuild the cache as the last step of every deploy, on every instance, in the production environment. Move non-trivial closure routes into controllers. And keep route caching out of your local workflow, so a stale cache never makes you doubt code that is actually correct.

Related reading: this series starts with Part 1: Laravel Routing Basics, then Part 2: Route Model Binding and Part 3: Middleware.


Originally published on DEV Talk.

Top comments (0)