skip to content

In Laravel 13, can routes defined with closures be cached by php artisan route:cache, and what can still make route caching fail?

level: middleimportance: should knowfreq 38%

answer

  1. older advice said controllers only
  2. prepareForSerialization() on each route
  3. SerializableClosure::unsigned wraps closures
  4. captured values must serialize
  5. missing() closures are wrapped too

basics

~20 s

Yes. Laravel 13's route:cache wraps each closure action in SerializableClosure::unsigned() and stores it in the cached route file, so closure routes work cached. Caching still fails when a closure captures a value PHP cannot serialize.

solid answer

~40 s

In Laravel 13, `php artisan route:cache` calls `prepareForSerialization()` on every route; when the action is a `Closure` it is replaced by `serialize(SerializableClosure::unsigned($closure))` from `laravel/serializable-closure` (v2 is required since 13.0), and a `missing()` closure is wrapped the same way. So `Route::get('/', fn () => view('welcome'))` caches fine. The old rule "closure routes cannot be cached" dates from older releases. What can still break: a closure that captures an object PHP refuses to serialize, such as a PDO connection, makes the command throw; and at request time `Route::runCallable()` unserializes the action with `allowed_classes` limited to the serializable-closure classes, so a captured object of your own class comes back as `__PHP_Incomplete_Class`. Capture scalars and arrays only. Controllers remain the tidier choice for anything non-trivial, but not because of caching.

code

php · 12 lines
php
<?php

use Illuminate\Support\Facades\Route;

// Caches in Laravel 13: the closure is wrapped with SerializableClosure::unsigned()
Route::get('/shifts', fn () => view('shifts.index'));

// Breaks route:cache: a PDO object refuses serialization
$pdo = new PDO('sqlite::memory:');
Route::get('/ping-db', function () use ($pdo) {
    return $pdo->query('select 1')->fetchColumn();
});

go deeper

for a junior

Recall that in current Laravel closure routes can be cached, and that route:cache belongs in the production deploy.

for a middle

Explain how prepareForSerialization() wraps closures with SerializableClosure and what a captured value must satisfy.

for a senior

Spot route files that break or behave differently once cached: unserializable captures, side effects, and environment-dependent registrations.

for a principal

Set a team convention on closures versus controllers based on readability and testing, not on outdated caching limits.

## The question behind the question For years a common piece of Laravel advice was: "do not use closures in your route files, because `route:cache` cannot serialize them." Interviewers still ask it to see whether a candidate's knowledge is current. In Laravel 13 the answer is that **closure routes can be cached**. ## How route caching works `php artisan route:cache` (`RouteCacheCommand`) does four things: 1. calls `route:clear` to remove any old cache file; 2. boots a fresh copy of the application and collects every registered route; 3. calls `prepareForSerialization()` on each `Illuminate\Routing\Route`; 4. writes the compiled collection to `bootstrap/cache/routes-v7.php`. On later requests the routing provider sees that `routesAreCached()` is true, calls `loadCachedRoutes()`, and never executes the route files. ## What happens to a closure `Route::prepareForSerialization()` in Laravel 13 contains this logic: - if `$this->action['uses']` is a `Closure`, replace it with `serialize(SerializableClosure::unsigned($closure))`; - if the route has a `missing` closure (the callback run when implicit model binding finds nothing), wrap it the same way; - compile the route and drop the router and container references. `SerializableClosure` comes from the `laravel/serializable-closure` package. It captures the closure's source code, its bound variables and its scope so the closure can be rebuilt later. The **unsigned** variant does not sign the payload with a secret key; that is acceptable because the payload is a file the application wrote itself. Laravel 13 requires `laravel/serializable-closure` `^2.0.10`; support for v1 was removed in 13.0. ```php <?php use Illuminate\Support\Facades\Route; // Cached fine in Laravel 13 Route::get('/', fn () => view('welcome')); $region = 'north'; Route::get('/rota', fn () => view('rota', ['region' => $region])); ``` ## What can still go wrong Closure support does not mean every route file caches cleanly: - **Unserializable captured values.** Whatever the closure captures with `use` (or through arrow-function auto-capture) is serialized with it. An object that refuses serialization, such as a `PDO` connection, makes `route:cache` throw instead of writing the file. - **Captured objects at request time.** `Route::runCallable()` calls `unserialize()` with an `allowed_classes` list that names only the serializable-closure classes. A captured instance of any other class, such as an application value object, therefore comes back as `__PHP_Incomplete_Class` and fails when the closure uses it. Scalars and arrays are unaffected. - **Logic that only ran while the file executed.** With a route cache, `routes/web.php` does not run at request time. Any side effect placed in a route file, such as registering a binding or reading a request value to decide which routes exist, is frozen at cache time or skipped. - **Conditional routes.** A route file that registers routes only in some environments is evaluated once, when the cache is built, in the environment of that build. - **Duplicate route names.** Two routes with the same name make `route:cache` fail during serialization; that route-naming rule is covered with route names themselves. ## How a cached closure runs The cached route file stores the action as a serialized string. On a request that matches the route, `Route::runCallable()` checks whether the action is a serialized closure, unserializes it with the restricted class list, calls `getClosure()` on the result and invokes it with the resolved parameters. Two consequences follow: - the closure's **code** is whatever it was when `route:cache` ran, so editing the route file changes nothing until the cache is rebuilt; - the closure's **captured values** are rebuilt from the cache file on each request, so they behave like constants baked into the release. The restricted `allowed_classes` list is a hardening choice: even though the file is written by the application, unserializing arbitrary classes from it is avoided. ## Closures or controllers? | Concern | Closure route | Controller route | |---|---|---| | Route caching in Laravel 13 | works | works | | Captured variables | serialized into the cache file | none | | Readability in `route:list` | shows `Closure` | shows `Controller@method` | | Testing and reuse | logic lives in the route file | logic lives in a class | The honest answer in an interview: caching is no longer a reason to avoid closures, but controllers keep route files short, show up clearly in tooling, and keep business logic in testable classes. Small `Route::get('/', fn () => view('welcome'))` style routes are fine to keep. ## Version note The behaviour described here is Laravel 13 (framework 13.34). Older releases rejected closure routes during `route:cache`, which is where the outdated advice comes from; if you maintain a very old application, check its version before relying on cached closures.

  • Why does Laravel use SerializableClosure::unsigned() rather than a signed closure for the route cache?
    A signed payload protects against tampering when serialized closures travel through untrusted storage. The route cache is a PHP file the application writes into its own `bootstrap/cache` directory and then `require`s, so there is no untrusted hop to defend; anyone who can alter that file can already alter the application code.
  • A cached Laravel 13 closure route captures an App\Support\Region object with use ($region); why does it fail at request time although route:cache succeeded?
    `route:cache` serialized the object happily, but `Route::runCallable()` unserializes cached closures with an `allowed_classes` list naming only the serializable-closure classes. PHP turns any other object in the payload into `__PHP_Incomplete_Class`, so calling a method on `$region` fails. Capture scalars or arrays, resolve the object inside the closure, or move the logic to a controller.
  • A route file registers a debug route only when config('app.debug') is true; what happens with route caching?
    The condition is evaluated once, when `route:cache` boots the application to collect routes. Whatever `app.debug` was in that build's environment decides whether the route is in the cache, and later changes have no effect until the cache is rebuilt. Environment-dependent routes are a reason to build caches in the target environment.

saying these in an interview costs you the question

  • route:cache always fails when any closure route exists
  • Cached closure routes lose captured strings and arrays
  • Closure routes silently disappear from the route cache
  • Route files still execute on every request after route:cache
  • Any serializable object is safe to capture in a cached closure route