DEV Community

Cover image for PHP OPcache Preloading in 2026: The 3 Mistakes That Make It Slower
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP OPcache Preloading in 2026: The 3 Mistakes That Make It Slower


A team I talked to last month turned on OPcache preloading expecting a 10% latency win. They got a 4% regression and 800MB of extra resident memory per FPM worker. Their preload script was the one the framework's README suggested: walk vendor/, eager-parse everything.

That's the failure mode. Preloading sounds like "more cache = faster," and it isn't. It's a knife with a sharp edge on both sides. Used on the right ~200 files, it shaves real time off every request. Used on 18,000 files, it makes cold-start worse and your workers fatter without moving p99.

Here are the three mistakes I see most often, the preload script that actually helps, and how to measure whether any of it mattered.

What preloading does

When php-fpm starts a worker, OPcache normally compiles each PHP file the first time it's required, stores the bytecode in shared memory, and reuses it on subsequent requests. Preloading moves that compile step to FPM startup. You point opcache.preload at a script; that script calls opcache_compile_file() on whatever paths you choose; the resulting opcodes get linked into a special "persistent" region of OPcache that lives for the lifetime of the master process.

Two real wins fall out of this:

  1. Skip the first-request compile penalty for files you preloaded. They're already opcodes when the first request lands.
  2. Link classes and functions globally. Preloaded classes are visible to every request without require or autoload roundtrips. The autoloader still runs, but class_exists() returns true immediately for anything preloaded.

The catch: preloaded code is immutable until the FPM master restarts. You can't edit a preloaded file and have the change pick up. That single constraint is where most of the mistakes come from.

Mistake 1: preloading every file in vendor

The first instinct is "preload everything, win everywhere." Walk vendor/, glob *.php, feed each one to opcache_compile_file(). On a typical Laravel 12 app that's somewhere between 12,000 and 25,000 files. What you actually get:

  • Memory per worker balloons. Persistent OPcache memory is shared across workers in theory, but the linked symbol table, the class hierarchy, and the resolved constants all take real RAM. I've seen memory_get_usage(true) at worker boot go from 28MB to 380MB after a "preload everything" change.
  • Cold-start gets worse. FPM startup goes from ~2 seconds to 20-40 seconds because preloading is synchronous. During a deploy, your systemctl reload php-fpm window stretches accordingly. If you're doing rolling deploys behind a load balancer, that's extra time with reduced capacity.
  • Most of those files are never hit on a given request. A typical request touches maybe 200-400 classes. The Stripe SDK's full namespace, the AWS PHP SDK's entire service surface, the Symfony components your app doesn't use. None of that needs to be in memory.

What to preload instead: the hot path. Concretely, on a Laravel app, that's the router, the container, the request lifecycle, Eloquent's base classes, the cache and queue drivers you actually use, plus your own application's interfaces and base classes. That list is closer to 150-400 files, not 18,000.

If you can't manually curate, a reasonable heuristic is "files referenced by the autoloader's classmap for classes that show up in a 100-request sample of your hottest endpoints." That's the basis of the script below.

Mistake 2: not invalidating on deploy

Preloaded code is stale code the moment you change it. The OPcache validation flags (opcache.validate_timestamps, opcache.revalidate_freq) don't apply to preloaded files. They're locked in until the FPM master restarts.

Teams forget this because the rest of OPcache is forgiving. You ship a hotfix, you bump revalidate_freq=0 in dev, you trust mtime checks. Preloading throws all that out. The deploy story for an app with preloading enabled is:

# deploy script: minimum viable
rsync -a --delete build/ /var/www/app/
systemctl reload php-fpm   # NOT enough: preload is unchanged
systemctl restart php-fpm  # this is what you need
Enter fullscreen mode Exit fullscreen mode

reload re-reads the FPM config but doesn't re-run the preload script. Only restart (or a SIGUSR2 to the master in some setups) re-runs preloading. If your deploy pipeline runs reload, you have a stale preload bug waiting to bite. The symptom is fun: new requests partially see new code (the autoloaded, non-preloaded files) and partially see old code (the preloaded classes), and you get errors like Method MyApp\OrderService::cancel() does not exist even though it clearly does in the source.

The fix is one of:

  1. Switch to restart instead of reload in your deploy. Costs you the running connections; if you're behind a load balancer with health checks and graceful drain, you don't notice.
  2. Roll workers via deployer hooks. Tools like Deployer's php-fpm:reload task should be replaced with a full restart task for preloading apps.
  3. Use atomic symlink swaps with a worker-aware restart. If current points to releases/A and you swap to releases/B, you still need to restart FPM. The symlink alone doesn't help because preloaded paths are resolved to absolute paths at preload time, not on each request.

Mistake 3: mixing preload with Octane / FrankenPHP

This one trips people who are migrating from classic FPM to a long-lived runtime. Laravel Octane (with Swoole or RoadRunner), FrankenPHP, and Swoole-direct apps already keep your app booted in memory between requests. The whole class graph is alive in the worker. Preloading adds nothing because the bootstrap-cost-on-first-request problem doesn't exist. The first request already paid it, and request 2 through 1,000,000 reuse the in-memory app instance.

Worse, preloading and long-lived runtimes interact badly:

  • FrankenPHP (documented behavior) supports preloading via the embedded PHP, but the preload script runs once per worker, not once per process. With --num-threads workers, you pay the preload cost N times.
  • RoadRunner runs PHP workers as separate processes. Preloading runs in each worker. The startup penalty multiplies, and since RoadRunner workers already cache the app instance, the runtime win is approximately zero.
  • Swoole with octane:start --workers=8 gives you 8 isolated PHP interpreters. Preloading each of them is 8× the parse work for the same upside the in-memory app instance already gives you.

The rule of thumb: pick one optimization, not both. If you're on classic php-fpm, preloading is worth tuning. If you're on Octane, FrankenPHP, RoadRunner, or Swoole, your effort is better spent on connection pooling, worker count tuning, and memory leak hunting. Preload is solving the wrong problem for that architecture.

A real preload script

Here's a preload script that targets a Laravel-ish app's hot classes without going overboard. Drop it at /var/www/app/preload.php and point opcache.preload at it in your php.ini.

<?php
// preload.php: runs once at FPM master startup.
// Goal: eager-compile the ~200 files every request touches.
// NOT goal: walk vendor/ blindly.

declare(strict_types=1);

$root = '/var/www/app';

// the framework hot path (Laravel 12 example; adapt for your stack)
$hot = [
    'vendor/laravel/framework/src/Illuminate/Foundation/Application.php',
    'vendor/laravel/framework/src/Illuminate/Container/Container.php',
    'vendor/laravel/framework/src/Illuminate/Http/Request.php',
    'vendor/laravel/framework/src/Illuminate/Http/Response.php',
    'vendor/laravel/framework/src/Illuminate/Routing/Router.php',
    'vendor/laravel/framework/src/Illuminate/Routing/Route.php',
    'vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php',
    'vendor/laravel/framework/src/Illuminate/Database/Eloquent/Builder.php',
    'vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php',
];

// your application's domain layer: the classes hit on every request
$app = glob($root . '/app/Domain/**/*.php', GLOB_BRACE) ?: [];
$app = array_merge($app, glob($root . '/app/Http/Controllers/*.php') ?: []);
$app = array_merge($app, glob($root . '/app/Models/*.php') ?: []);

$compiled = 0;
$skipped = 0;

foreach (array_merge(array_map(fn($p) => "$root/$p", $hot), $app) as $file) {
    if (!is_file($file)) {
        $skipped++;
        continue;
    }
    // opcache_compile_file is the preload primitive.
    // It links the file's classes into persistent OPcache.
    if (opcache_compile_file($file)) {
        $compiled++;
    }
}

error_log("preload: compiled=$compiled skipped=$skipped");
Enter fullscreen mode Exit fullscreen mode

In php.ini:

; php.ini: the two settings that matter
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-data

; everything else can stay default
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0  ; in production
Enter fullscreen mode Exit fullscreen mode

opcache.preload_user is required when FPM runs as root (which it does on most Linux installs; the master is root, workers drop to www-data). PHP refuses to preload as root. If you forget this, you get Preloading doesn't work in CLI under root user, please set opcache.preload_user in the FPM error log and preloading silently does nothing.

Measuring impact with opcache_get_status()

Toggle preloading off and on, run your real workload (not ab against /), and read the numbers. This 20-line script gives you the signal:

<?php
// preload-check.php: drop into a route or run via php-fpm-curl.
$status = opcache_get_status(false);

$preload = $status['preload_statistics'] ?? null;

if ($preload === null) {
    echo "preloading: not active\n";
} else {
    printf(
        "preloaded: %d functions, %d classes, %.1f MB\n",
        $preload['num_functions'],
        $preload['num_classes'],
        $preload['memory_consumption'] / 1024 / 1024
    );
}

printf(
    "opcache: %d scripts cached, %d hits, %d misses (%.2f%% hit rate)\n",
    $status['opcache_statistics']['num_cached_scripts'],
    $status['opcache_statistics']['hits'],
    $status['opcache_statistics']['misses'],
    $status['opcache_statistics']['opcache_hit_rate']
);
Enter fullscreen mode Exit fullscreen mode

What good numbers look like on a tuned Laravel app: 200-500 preloaded classes, 60-90MB preload memory, opcache hit rate above 99.5% after warmup. If you see 3,000+ classes preloaded and 300MB+ of preload memory, you're in mistake-1 territory. If num_cached_scripts keeps growing past 5,000, your hot-path estimate was too small and the rest of the app is paying first-request compile costs anyway. Widen the preload list.

When preloading isn't worth it

Be honest about the size of your app. Three cases where I'd skip preloading entirely:

  • Small apps (<2,000 PHP files total). The compile cost is already in the noise. Tune opcache.max_accelerated_files and call it done.
  • Octane / FrankenPHP / RoadRunner / Swoole. Already covered above. The long-lived runtime makes preload redundant.
  • Apps that deploy multiple times per day with reload not restart. The operational risk of stale preloads outweighs the latency win until the deploy story is fixed.

The cases where preloading earns its place: medium-to-large classic php-fpm apps (5,000-50,000 files), p99 sensitive workloads, hot endpoints that re-bootstrap the framework on every request. There, a curated 200-file preload script gives you a real 5-15% latency improvement on cold-ish workers and shaves p99 visibly.

The version with no curation, walking vendor blindly, will hurt you. That's the whole article.


If this was useful

OPcache preloading is one of those PHP features that quietly punishes the "turn it on and hope" approach. The same logic applies one layer up: when your app outgrows the framework defaults, the cost of skipping architecture starts showing up in deploy times, in memory profiles, in the parts of the codebase nobody wants to touch. Decoupled PHP is the book I wrote about exactly that. It's about keeping your domain logic crisp enough that runtime decisions like preloading become tuning, not rescue work.

What's the wildest preload misfire you've shipped, and how did you catch it? Drop the war story in the comments.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)