A Laravel app doing 40ms of framework boot on every request is not a Laravel problem. It is a PHP configuration problem, and on most servers I inherit it comes down to OPcache running on stock defaults with preloading switched off entirely. This is how I configure Laravel OPcache preloading in production, and — more importantly — how I prove it did anything.
Baseline your OPcache status and throughput#
You cannot tune what you have not measured, and the first trap is measuring the wrong process. PHP's CLI has its own opcode cache, completely separate from the one PHP-FPM uses, so php -r 'print_r(opcache_get_status());' tells you nothing about the cache your web traffic actually hits. Read the status from a route your web server serves.
// routes/web.php — temporary, delete once you have your numbers
Route::get('/__opcache', function () {
abort_unless(request()->query('token') === config('services.opcache.token'), 404);
$status = opcache_get_status(false); // false = skip the per-script list
return response()->json([
'enabled' => $status['opcache_enabled'],
'used_mb' => round($status['memory_usage']['used_memory'] / 1048576, 1),
'free_mb' => round($status['memory_usage']['free_memory'] / 1048576, 1),
'wasted_mb' => round($status['memory_usage']['wasted_memory'] / 1048576, 1),
'cached_scripts' => $status['opcache_statistics']['num_cached_scripts'],
'max_cached_keys' => $status['opcache_statistics']['max_cached_keys'],
'hit_rate' => round($status['opcache_statistics']['opcache_hit_rate'], 2),
'oom_restarts' => $status['opcache_statistics']['oom_restarts'],
]);
});
Two numbers decide everything that follows. If oom_restarts is above zero, opcache.memory_consumption is too small and the cache is thrashing. If cached_scripts is sitting near max_cached_keys, opcache.max_accelerated_files is too small and files are being evicted on a live server.
Then get a throughput baseline with warm traffic against a representative endpoint.
// baseline.js — k6 run --vus 50 --duration 60s baseline.js
import http from 'k6/http';
import { check } from 'k6';
export default function () {
const res = http.get(`${__ENV.TARGET}/dashboard`);
check(res, { 'status is 200': (r) => r.status === 200 });
}
Record the p95 and requests/sec. Everything after this is judged against that number, not against a feeling. If you want request-level attribution rather than an aggregate, Pulse, Nightwatch and OpenTelemetry each cover a different slice of production observability.
Set the production OPcache ini values#
The stock OPcache defaults were sized for a small app, not for a Laravel install with a few hundred packages in vendor/. Count your real PHP files first, then size the cache to hold all of them with headroom, and turn off the filesystem stat() call that runs on every include.
# Every PHP file the app can possibly load, vendor included
find . -path ./node_modules -prune -o -name '*.php' -print | wc -l
A typical Laravel 13 app with Horizon, Cashier and a handful of Spatie packages lands somewhere between 12,000 and 20,000 files. Size from that:
; /etc/php/8.4/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.enable_cli=0
; Shared memory for compiled bytecode. Default is 128 — too tight
; once vendor/ is fully warm on a busy app.
opcache.memory_consumption=256
; Class, function and file names are interned once and shared.
; Default is 8; Laravel's namespace depth eats it quickly.
opcache.interned_strings_buffer=32
; PHP rounds this up to the next prime in a fixed set
; (…7963, 16229, 32531, 65407…), so pick a member of that set.
opcache.max_accelerated_files=32531
; The big one: stop stat()-ing every file on every request.
opcache.validate_timestamps=0
; Leave comments in. Turning this off breaks any package that
; reads docblocks at runtime.
opcache.save_comments=1
; Allow more wasted memory before a full restart is triggered.
opcache.max_wasted_percentage=10
opcache.validate_timestamps=0 is the setting that earns its keep. With it on (the default), PHP checks the modification time of every included file on a schedule set by opcache.revalidate_freq; with it off, that entire class of syscalls disappears and revalidate_freq is ignored outright. It also means a deploy changes nothing until you reload PHP-FPM, which is the whole of step six.
Worth being clear about what this is not: OPcache caches compiled PHP bytecode, not the results of your queries or your Cache::remember() calls. Those are a separate problem with their own tags, locks and invalidation strategy. Tuning one does nothing for the other.
Write a focused Laravel preload script#
Preloading, added in PHP 7.4, runs one arbitrary PHP file when the engine starts and makes every function, class, interface and trait it touches globally available to every subsequent request. Constants are not included. The instinct is to preload the whole of vendor/; resist it, because you are trading permanent baseline memory for compilation you may never save.
<?php
/**
* preload.php — executed once when PHP-FPM starts.
* Referenced by opcache.preload in php.ini.
*/
// The Composer autoloader must be live so that parent classes,
// interfaces and traits can be resolved during linking.
require __DIR__.'/vendor/autoload.php';
/** @var list<string> $directories Hot paths only — not all of vendor/. */
$directories = [
__DIR__.'/vendor/laravel/framework/src/Illuminate/Foundation',
__DIR__.'/vendor/laravel/framework/src/Illuminate/Container',
__DIR__.'/vendor/laravel/framework/src/Illuminate/Routing',
__DIR__.'/vendor/laravel/framework/src/Illuminate/Http',
__DIR__.'/vendor/laravel/framework/src/Illuminate/Database',
__DIR__.'/vendor/laravel/framework/src/Illuminate/Support',
__DIR__.'/vendor/laravel/framework/src/Illuminate/View',
__DIR__.'/app',
];
// Console commands, migrations, stubs and tests never run on a web
// request — preloading them is pure memory cost.
$skip = '#/(tests?|Tests?|stubs?|Stubs?|database/migrations|Console/Commands)/#';
foreach ($directories as $directory) {
if (! is_dir($directory)) {
continue;
}
$files = new RegexIterator(
new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS)
),
'/\.php$/'
);
foreach ($files as $file) {
$path = $file->getPathname();
if (preg_match($skip, $path)) {
continue;
}
// compile, do not execute: order-independent and safe for files
// with side effects at the top level
opcache_compile_file($path);
}
}
The choice of opcache_compile_file() over require is deliberate. require executes the file, which means side-effectful files run at server start and classes must be loaded strictly parent-first. opcache_compile_file() parses without executing, so files can be compiled in any order and a class defined before its parent still links correctly. The trade-off is that conditionally declared functions — anything inside an if block — are not picked up.
Point opcache.preload at the script and restart PHP-FPM#
Preloading is a global engine setting, not a per-pool one, so it has to go in a php.ini file that the master process reads. Putting opcache.preload in a PHP-FPM pool config silently does nothing, which is the single most common reason people conclude preloading "doesn't work".
; /etc/php/8.4/fpm/conf.d/10-opcache.ini — append to the block above
opcache.preload=/var/www/app/current/preload.php
; Required when the FPM master starts as root before dropping
; privileges. Preloading as root is refused by default.
opcache.preload_user=www-data
sudo systemctl reload php8.4-fpm
A reload re-execs the FPM master, which is what re-runs the preload script — a worker-only graceful restart will not. If you deploy through an atomic symlink, note that current is resolved when the master starts, so the reload has to happen after the symlink flips, never before.
Verify which classes were actually preloaded#
Do not assume it worked. opcache_get_status() grows a preload_statistics key when preloading is active, and its absence is your signal that the directive was ignored — usually because it was set in the wrong file or the preload script fatally errored at startup. Extend the temporary route from step one.
$status = opcache_get_status(false);
return response()->json([
'preloading_active' => isset($status['preload_statistics']),
'preloaded_classes' => count($status['preload_statistics']['classes'] ?? []),
'preloaded_functions' => count($status['preload_statistics']['functions'] ?? []),
'preloaded_scripts' => count($status['preload_statistics']['scripts'] ?? []),
'preload_mb' => round(($status['preload_statistics']['memory_consumption'] ?? 0) / 1048576, 1),
]);
Check the FPM error log at the same time. A wall of Can't preload unlinked class … warnings means a preloaded class extends something the script never reached — most often an optional dependency that is not installed. Those warnings are not fatal, the class simply is not preloaded, but each one is a file you paid to parse for nothing. Add its directory to $skip and reload.
Watch preload_mb against opcache.memory_consumption. Preloaded symbols are held permanently in the same shared memory pool, so an aggressive preload script can starve the ordinary bytecode cache and push oom_restarts up — a net loss dressed up as an optimisation.
Reset the OPcache on every deploy#
With validate_timestamps=0, PHP will happily serve last week's bytecode from memory forever. php artisan optimize does not help: Artisan runs in the CLI SAPI, which has its own separate cache, so opcache_reset() from the command line resets nothing that your web traffic touches. Reloading the FPM master is the reliable answer.
# In the deploy hook, immediately after the release symlink flips
sudo systemctl reload php8.4-fpm
If the deploy user cannot be given that sudo rule, cachetool talks to the FastCGI socket directly and resets the cache the way a web request would.
curl -sLO https://github.com/gordalina/cachetool/releases/latest/download/cachetool.phar
php cachetool.phar opcache:reset --fcgi=/run/php/php8.4-fpm.sock
There is a catch that matters once preloading is on: cachetool clears the script cache but does not re-run the preload script, because preloading only happens at master start. If you preload, the FPM reload is mandatory and cachetool is not a substitute. On a blue-green setup where nginx switches upstreams between two pools this is free — the idle pool restarts cold and comes up preloaded before it takes traffic. In a container world it is also free, because a Kamal deploy replaces the container outright and the new PHP process preloads on boot.
Re-run the load test and compare the numbers#
Run the identical k6 script against the same endpoint with the same VU count, and compare p95 and requests/sec against the baseline you recorded in step one. Also re-read /__opcache — you want a hit rate above 99%, oom_restarts at zero, and cached_scripts comfortably below max_cached_keys.
Set your expectations honestly, because the three changes are not equal. Going from no OPcache to OPcache is transformative and not really optional. Flipping validate_timestamps to 0 on top of that is a small, consistent win — you are deleting syscalls, not work. Adding preloading on top of both is usually a low single-digit percentage on modern PHP, because the inheritance cache introduced in PHP 8.1 already caches much of the class-linking work preloading used to be uniquely good at.
If the preloading delta is inside your run-to-run noise, delete the preload script. A configuration you cannot measure is a configuration you will not maintain.
Avoid the preloading gotchas that bite in production#
Most preloading failures are environmental rather than conceptual, and they fail quietly. Preloading is not supported on Windows at all. Constants defined in preloaded files are not preloaded, so a file may still need to be included at runtime purely to get its constants. And because preloaded scripts can only be cleared by restarting the PHP process, this is a production-only feature — running it locally just means your code changes stop taking effect.
Two more that cost me real time. opcache.save_comments=0 looks like free memory until a package that reads docblocks at runtime starts throwing; leave it at 1. And JIT is not the same lever — PHP 8.4 changed the default to opcache.jit=disable with opcache.jit_buffer_size=64M, so a config that used to enable JIT by setting only the buffer size now silently does not. Enabling it with opcache.jit=tracing rarely moves the needle on IO-bound Laravel request handling, though it can pay off for genuinely CPU-heavy work.
Decide whether preloading is worth it on Octane#
Preloading and Octane solve overlapping problems, so running both is often redundant. Octane already keeps the framework booted in a long-lived worker, which is the expensive part preloading was trying to avoid — the marginal gain from preloading on top is small, and it mostly shows up as faster worker cold starts rather than lower steady-state latency. The OPcache ini values still matter under Octane; the preload script usually does not.
My rule: on plain PHP-FPM, set the ini values, measure, and add preloading only if the load test justifies the extra memory. On Octane, set the ini values and put the effort into worker recycling and memory caps instead, where the wins actually are. And if you are weighing a managed platform that handles this layer for you, the Vapor versus Forge trade-offs are the next thing to read.
FAQ#
What is OPcache preloading in PHP?
Preloading is a PHP 7.4+ feature where OPcache runs a single script when the PHP engine starts and keeps every function, class, interface and trait it loads resident in shared memory for all subsequent requests. Those symbols are then available without an autoloader lookup or a compile step. Constants are not preloaded, and the preloaded set can only be cleared by restarting the PHP process, which makes it a production-only feature.
How do I enable OPcache preloading for Laravel?
Write a preload.php in your project root that requires the Composer autoloader and then calls opcache_compile_file() on the framework and application directories your requests actually hit. Point opcache.preload at that file from a php.ini file — not a PHP-FPM pool config, because preloading is a global engine setting — and set opcache.preload_user to your web user if the FPM master starts as root. Reload PHP-FPM, then confirm a preload_statistics key appears in opcache_get_status().
What OPcache settings should I use in production?
Start with opcache.memory_consumption=256, opcache.interned_strings_buffer=32, opcache.max_accelerated_files=32531 and opcache.validate_timestamps=0, then adjust against your real file count. Keep opcache.save_comments=1 so packages that read docblocks at runtime keep working, and leave opcache.enable_cli=0 since the CLI cache is separate and rarely useful. Check oom_restarts and num_cached_scripts after a day of real traffic and raise the memory or file limits if either is under pressure.
Do I need to restart PHP-FPM after deploying with OPcache?
Yes, if you have set opcache.validate_timestamps=0, because PHP will otherwise keep serving the previously compiled bytecode indefinitely. Reloading PHP-FPM after the release symlink flips is the reliable fix; php artisan optimize will not do it, since Artisan runs in the CLI SAPI with a completely separate opcode cache. Tools like cachetool can reset the FPM cache over the FastCGI socket, but they do not re-run the preload script — only a master restart does that.
Does OPcache preloading work with Laravel Octane?
It works, but the payoff is much smaller. Octane keeps the application booted inside a long-lived worker, so the compile-and-boot cost that preloading eliminates has largely been paid once already at worker start. OPcache itself is still worth configuring properly under Octane for faster worker startup and shared interned strings, but I would tune worker recycling and memory limits before adding a preload script.
How much faster does OPcache make Laravel?
Enabling OPcache at all is the transformative step — compiling every PHP file on every request is easily a multiple-times penalty, which is why it ships enabled on most production builds. Beyond that the gains are incremental: setting validate_timestamps=0 removes a filesystem check per included file for a small, consistent improvement, and preloading on top typically adds low single digits on PHP 8.1+, where the inheritance cache already handles much of the class-linking work. The honest answer is that it depends entirely on your app, which is why the before-and-after load test is the only part of this you should not skip.