route:cache succeeded and every request is a 500
The loud serialization failure is the safe one. The dangerous case is the deploy step that reports DONE and takes the whole application down on the next request.
ON THIS PAGE
A green deploy and a dead application
The deploy script ran clean. Every step printed DONE, the exit code was zero, the pipeline went green and Slack said so. Then the health check failed, and every request — every artisan command too — came back with the same thing:
ParseError: syntax error, unexpected token "@", expecting ")"
at bootstrap/cache/routes-v7.php:2252
Nothing in the application changed except a default value on one route. php artisan optimize reported routes .. 9.72ms DONE while writing a file that PHP cannot parse.
This is worth understanding in detail, because the instinct — "closures break route caching" — is no longer true, and acting on it sends you looking in the wrong place.
Closure routes cache now
The rule most of us carry around is that route:cache refuses a closure route. Check it against the current framework rather than against memory:
Route::get('/health', function () {
return 'ok';
});
Cache it and see what the command actually says:
php artisan route:cache
# INFO Routes cached successfully.
No exception. Route::prepareForSerialization() runs the closure through SerializableClosure::unsigned() and stores the serialized result, for the route action and for any missing() handler. Closure routes work fine from the cache.
What that buys you is a subtler bug rather than a blocked deploy. A closure captured with use has its captured values frozen at the moment route:cache ran, and the frozen value is baked into the cache file on disk:
$builtAt = now()->toDateTimeString();
Route::get('/build', function () use ($builtAt) {
return $builtAt.' | live: '.now()->toDateTimeString();
});
Uncached, both halves move together. Cached, the first one is a fossil:
2026-09-22 18:52:14 | live: 2026-09-22 18:52:21
Grep the cache file and the timestamp is sitting there as a literal. Any closure route that captures configuration, a feature flag, a computed constant or a date now answers with whatever was true on the deploy machine at build time, forever, and it does it without a single error.
What actually refuses to serialize
The failure everyone remembers does exist — it just fires on what the closure holds, not on the closure:
$pdo = DB::connection()->getPdo();
Route::get('/debug', function () use ($pdo) {
return get_class($pdo);
});
The closure serializes; the connection inside it does not:
Exception: Serialization of 'Pdo\Mysql' is not allowed
at vendor/laravel/framework/src/Illuminate/Routing/Route.php:1547
Database connections, open file handles, sockets, anything PHP marks unserializable. Capture one in a route closure and the cache build stops there.
Under php artisan optimize, that surfaces as a failed task with the exception printed underneath:
config .. 8.25ms DONE
events .. 0.78ms DONE
routes .. 7.39ms FAIL
This is the good outcome. The command threw before writing anything, so there is no route cache file, so the application falls back to reading your route files on each boot. Slower, and entirely functional. A loud failure during a deploy is a deploy you can stop; the application underneath it is still serving.
One thing to clear up while you are in this code: an inline closure passed to middleware() is not a caching problem at all. Route::middleware() casts every entry with (string), so a closure fails with Object of class Closure could not be converted to string at route registration, cached or not. If someone has told you route caching broke their closure middleware, the middleware was never working.
The failure that writes the file anyway
Now the case that took the site down. Route actions are serialized, but everything else about a route is written out by var_export():
return str_replace('{{routes}}', var_export($routes->compile(), true), $stub);
var_export does not refuse objects. For an object it emits a ::__set_state() call and moves on, with no check that the resulting text is valid PHP or that the class can rebuild itself. Put an anonymous class in a route default:
Route::get('/report', fn () => 'ok')
->defaults('flag', new class { public int $a = 1; });
Nothing about that looks dangerous, and the command agrees:
php artisan route:cache
# INFO Routes cached successfully.
Exit code zero. And inside bootstrap/cache/routes-v7.php:
'flag' =>
\class@anonymous\/app/routes/web.php:57$113::__set_state(array(
'a' => 1,
)),
An anonymous class name contains @ and a file path. That is not an identifier. The file is not parseable PHP, so it does not fail at the route layer or produce a 404 on one endpoint — it fails while the framework is loading the cache, which means every web request, every worker boot and every artisan command dies with the same ParseError.
Every artisan command includes the ones you would reach for to fix it. route:clear and optimize:clear both have to boot the application before they can delete anything, and booting is what is broken:
php artisan optimize:clear
# ParseError: syntax error, unexpected token "@", expecting ")"
There is exactly one way out, and it is not an artisan command:
rm -f bootstrap/cache/routes-v7.php
Worth knowing before you need it at 3am, rather than discovering it while every recovery command you try prints the same stack trace.
A named class hits the same wall one step later. A PDO instance in a route default exports as \Pdo\Mysql::__set_state(array()), which parses fine and then throws Error: Call to undefined method Pdo\Mysql::__set_state() on the next boot. Same shape: the cache command succeeds, the application is down, and the thing that broke it is a value nobody thought of as code.
The rule that falls out: a route definition may only carry scalars, arrays and strings outside its action. Defaults, where() constraints, names, prefixes. The moment an object gets in there, route:cache stops being a performance step and becomes a coin flip that your deploy script cannot read the result of.
Ordering, and the window it opens
route:cache starts by calling route:clear, then boots a completely fresh application from bootstrap/app.php to collect routes, then writes the file. Three consequences for a deploy script.
The cache is built from the code on disk at that instant. If your deploy caches routes and then swaps the release directory or symlink, you have compiled the old application's routes and pointed them at the new application's classes. Controllers that were renamed resolve to nothing. Cache routes after the code is in place, never before.
Because it clears first, there is a real interval — short, but not zero — where no route cache exists. Requests arriving then are served from your route files directly. That is a latency blip, not an error, and it is the reason a failed route:cache is survivable.
And the fresh application it boots reads bootstrap/cache/config.php if one is there. Route files that read config() at registration time will bake stale config into the route cache if you rebuild routes against a config cache from the previous release. Clear everything, then rebuild everything, in that order:
git pull origin main
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize:clear
php artisan optimize
The one step that script is missing is a check on its own work. optimize exiting 0 does not mean the application boots, as the anonymous-class case proves. Prove it:
php artisan optimize
php artisan route:list --except-vendor > /dev/null \
|| { rm -f bootstrap/cache/routes-v7.php; exit 1; }
route:list boots the application and loads the cache file exactly as a request does, so it exits 1 on a cache that optimize just reported as DONE. Deleting the file by hand — not with route:clear, which cannot boot — puts you back on uncached routes and a working site, and the non-zero exit fails the deploy instead of handing you a green pipeline in front of a dead application.
What to change
Search your route files for ->defaults( and for use ( on route closures. Anything in a default that is not a scalar, a string or an array of those is a latent outage; move it into the action or into a config value. Anything captured by use is a build-time snapshot, so either make it a config() call inside the closure or move the whole thing to a controller.
Then add the boot check to your deploy script. It is one line, it runs in under a second, and it is the only thing between "routes cached successfully" and finding out from your users.