Skip to content

[12.x] Store the compiled route cache as a serialized payload#60516

Open
jbidad wants to merge 1 commit into
laravel:12.xfrom
jbidad:route-cache-serialization-tests
Open

[12.x] Store the compiled route cache as a serialized payload#60516
jbidad wants to merge 1 commit into
laravel:12.xfrom
jbidad:route-cache-serialization-tests

Conversation

@jbidad

@jbidad jbidad commented Jun 15, 2026

Copy link
Copy Markdown

What & why

route:cache currently dumps the compiled route collection into the cache file as a var_export()ed PHP array. For large route tables this produces a multi-megabyte PHP file that has to be parsed and compiled on the first request after every deploy / opcache reset.

This PR keeps the cache file a require()-able PHP file, but stores the compiled route data as a serialized payload that is rehydrated with unserialize():

<?php

app('router')->setCompiledRoutes(
    unserialize('...compiled routes...')
);

Backward compatibility

Fully backward compatible:

  • The cache file is still a require()-able PHP file, so tooling that loads it directly (e.g. Testbench's defineCacheRoutes()) is unaffected.
  • RouteServiceProvider::loadCachedRoutes() loads serialized payloads through Router::setCompiledRoutes() and falls back to require() for legacy var_export() cache files, so applications that upgrade without re-running route:cache keep booting.

unserialize() runs on the application's own generated cache file — the same trust boundary as require()-ing it does today.

Tests

  • RouteCachingTest::testCachedRouteFileStoresASerializedPayload — runs the real route:cache command and asserts the generated file is a require()-able wrapper around an unserialize()d payload, and that the routes still resolve.
  • RouteCacheLoadingTest (new) — asserts routes resolve when loaded from the new serialized-payload file, a plain serialized file, and a legacy var_export() file (backward compatibility).

Benchmarks (before / after)

Reproducible script below. PHP 8.3, three route-table sizes. Cold = first load in a fresh php -n process (no opcache — i.e. the first request after a deploy / opcache reset / serverless cold start), process-startup subtracted. Warm = steady-state per-request cost once opcache has the file compiled.

routes format file size cold load (1st req) warm load (per req) break-even
3000 var_export 3.51 MB 20,716 µs ~0 µs
3000 serialize 2.08 MB (−41%) 8,423 µs (−59%) 2,472 µs ~5 requests
1500 var_export 1.75 MB 9,850 µs ~0 µs
1500 serialize 1.04 MB (−41%) 4,044 µs (−59%) 1,249 µs ~5 requests
300 var_export 0.35 MB 5,387 µs ~0 µs
300 serialize 0.21 MB (−41%) 393 µs 294 µs ~17 requests

Honest read of the numbers: serialize() makes the cache file ~40% smaller and the cold / first load 2–3× faster. Under a long-lived opcache process, however, var_export()'s constant array is interned by the Zend engine and returned for ~0 µs/request, while serialize() pays unserialize() on every request — so var_export() repays its cold-start cost within ~5 requests and is ahead in steady state. The change is therefore most compelling for cold-start-dominated deployments (serverless / containers, opcache disabled, or very frequent deploys) and for shrinking the cache file on disk.

Reproducible benchmark script
<?php

/**
 * Route cache format benchmark: var_export() (current) vs serialize() (proposed).
 *
 * The route cache file is require()'d on every request, so the meaningful
 * comparison has three dimensions:
 *
 *   1. File size            - bytes on disk.
 *   2. Cold load            - parse + compile + execute in a fresh process with
 *                             no opcache (first request after a deploy / opcache
 *                             reset / serverless cold start). Measured with a
 *                             `php -n` subprocess, process-startup subtracted.
 *   3. Warm load            - steady-state per-request execution once compilation
 *                             is amortised by opcache. A var_export()'d constant
 *                             array is interned by the Zend engine and returned
 *                             for free; serialize() must run unserialize() every
 *                             request. Measured in-process, execute-only.
 *
 * Usage:
 *   php route_cache_benchmark.php [comma,separated,route,group,counts] [warm-iterations]
 *   php route_cache_benchmark.php 100,500,1000 20000
 *
 * Each "group" produces 3 routes (index/edit/update), so 1000 groups = 3000 routes.
 */

require __DIR__.'/vendor/autoload.php';

use Illuminate\Container\Container;
use Illuminate\Events\Dispatcher;
use Illuminate\Routing\Router;

$groupCounts = array_map('intval', explode(',', $argv[1] ?? '100,500,1000'));
$warmIterations = (int) ($argv[2] ?? 20000);
$coldReps = 15;

$binary = PHP_BINARY;
$tmp = sys_get_temp_dir();

/** Build a compiled route cache array for the given number of route groups. */
function compileRoutes(int $groups): array
{
    $router = new Router(new Dispatcher, new Container);

    for ($i = 0; $i < $groups; $i++) {
        $router->get("resource-{$i}", ['uses' => "App\\Http\\Controllers\\Controller{$i}@index"])
            ->name("resource.{$i}.index")->middleware(['web', 'auth']);

        $router->get("resource-{$i}/{id}/edit", ['uses' => "App\\Http\\Controllers\\Controller{$i}@edit"])
            ->name("resource.{$i}.edit")->where('id', '[0-9]+')->middleware(['web', 'auth']);

        $router->put("resource-{$i}/{id}", ['uses' => "App\\Http\\Controllers\\Controller{$i}@update"])
            ->name("resource.{$i}.update")->where('id', '[0-9]+')->middleware(['web', 'auth', 'verified']);
    }

    $router->getRoutes()->refreshNameLookups();
    $router->getRoutes()->refreshActionLookups();

    return [$router->getRoutes()->compile(), count($router->getRoutes()->getRoutes())];
}

function median(array $values): float
{
    sort($values);
    $n = count($values);

    return $n % 2 ? $values[intdiv($n, 2)] : ($values[$n / 2 - 1] + $values[$n / 2]) / 2;
}

/** Time a cold require() in a fresh `php -n` process (no opcache), startup subtracted. */
function coldLoadMicros(string $binary, string $file, int $reps): float
{
    $base = $load = [];
    $bin = escapeshellarg($binary);
    $requireCode = escapeshellarg('require '.var_export($file, true).';');

    for ($i = 0; $i < $reps; $i++) {
        $s = hrtime(true);
        shell_exec("$bin -n -r '1;'");
        $base[] = hrtime(true) - $s;

        $s = hrtime(true);
        shell_exec("$bin -n -r $requireCode");
        $load[] = hrtime(true) - $s;
    }

    return max(0, median($load) - median($base)) / 1e3;
}

printf("PHP %s  |  binary: %s\n", PHP_VERSION, $binary);
printf("cold reps: %d (php -n subprocess)  |  warm iterations: %d\n\n", $coldReps, $warmIterations);

printf("%-9s %-7s | %-22s | %-22s | %-22s | %s\n",
    'routes', 'fmt', 'file size', 'cold load (1st req)', 'warm load (per req)', 'break-even');
echo str_repeat('-', 110)."\n";

foreach ($groupCounts as $groups) {
    [$compiled, $routeCount] = compileRoutes($groups);
    $serialized = serialize($compiled);

    $fileVe = "$tmp/rc_bench_ve_{$groups}.php";
    $fileSe = "$tmp/rc_bench_se_{$groups}.php";
    file_put_contents($fileVe, "<?php\n\nreturn ".var_export($compiled, true).";\n");
    file_put_contents($fileSe, "<?php\n\nreturn unserialize(".var_export($serialized, true).");\n");

    // Warm, execute-only: var_export's interned constant array vs unserialize().
    $fn = "rc_build_$groups";
    $fnFile = "$tmp/rc_bench_fn_{$groups}.php";
    file_put_contents($fnFile, "<?php\n\nfunction $fn() {\n    return ".var_export($compiled, true).";\n}\n");
    require $fnFile;

    $x = $fn(); unset($x);                       // warm-up
    $start = hrtime(true);
    for ($i = 0; $i < $warmIterations; $i++) { $x = $fn(); unset($x); }
    $warmVe = (hrtime(true) - $start) / $warmIterations / 1e3;

    $x = unserialize($serialized); unset($x);    // warm-up
    $start = hrtime(true);
    for ($i = 0; $i < $warmIterations; $i++) { $x = unserialize($serialized); unset($x); }
    $warmSe = (hrtime(true) - $start) / $warmIterations / 1e3;

    $sizeVe = filesize($fileVe);
    $sizeSe = filesize($fileSe);
    $coldVe = coldLoadMicros($binary, $fileVe, $coldReps);
    $coldSe = coldLoadMicros($binary, $fileSe, $coldReps);

    // Requests until var_export's per-request saving repays serialize's cold lead.
    $breakEven = ($warmSe - $warmVe) > 0 ? ($coldVe - $coldSe) / ($warmSe - $warmVe) : INF;

    printf("%-9d %-7s | %12s bytes        | %10.1f us            | %10.1f us            |\n",
        $routeCount, 'var_exp', number_format($sizeVe), $coldVe, $warmVe);
    printf("%-9s %-7s | %12s bytes (%5.1f%%) | %10.1f us (%4.0f%%)     | %10.1f us            | ~%.0f requests\n",
        '', 'serial', number_format($sizeSe), 100 * $sizeSe / $sizeVe,
        $coldSe, 100 * $coldSe / max($coldVe, 0.01), $warmSe,
        is_finite($breakEven) ? $breakEven : 0);
    echo str_repeat('-', 110)."\n";

    @unlink($fileVe); @unlink($fileSe); @unlink($fnFile);
}

echo "\nReading the table:\n";
echo "  - serialize() makes the cache file ~40% smaller and the COLD (first) load ~2-3x faster.\n";
echo "  - But every WARM request pays unserialize(), while var_export()'s constant array is\n";
echo "    interned by the engine and costs ~0. 'break-even' is how few requests it takes for\n";
echo "    var_export to repay serialize's cold-start lead; after that var_export is ahead.\n";
echo "  - Net: serialize() wins for cold-dominated setups (serverless / opcache off / frequent\n";
echo "    deploys) and disk size; var_export() wins steady-state under opcache.\n";

Replace the var_export()ed PHP array in the route cache file with a serialized payload that is rehydrated via unserialize(). The cache file remains a require()-able PHP file, so previously generated caches and any tooling that loads it directly keep working.

The route service provider now loads serialized payloads natively through Router::setCompiledRoutes() and falls back to require() for legacy PHP cache files, keeping full backward compatibility for applications that upgrade without re-running route:cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant