OPcache in Docker: The Default Settings That'll Hurt You at Peak Load
PHP's OPcache defaults were written for a different era. In a Dockerized Laravel app under real traffic, they'll quietly kill your performance at the worst possible moment.
I've watched a Laravel app crater under Black Friday traffic because the OPcache memory_consumption was set to 128MB and the app had grown to the point where it couldn't fit. No alarm went off. No obvious error. Just PHP silently falling back to compiling files on every request while the CPU pegged at 100% and response times went from 80ms to 4 seconds. That experience changed how I configure every container we ship.
OPcache is one of those things developers take for granted because it works fine in development. Then you containerize your app, deploy it, and the defaults that shipped with your base image — or that you copy-pasted from a StackOverflow answer in 2019 — quietly become landmines.
What OPcache Actually Does (In Plain Terms)
PHP is a scripting language. Without a bytecode cache, every request causes PHP to read your source files from disk, lex them, parse them into an AST, and compile them into opcodes — the internal instruction set the Zend engine actually executes. For a Laravel app, that means loading vendor/autoload.php, the framework kernel, your service providers, middleware stack, models, controllers... easily 300-500+ files per cold request.
OPcache intercepts that compilation step, stores the resulting opcodes in shared memory, and serves them on subsequent requests without touching the filesystem. Done right, it's the single highest-leverage performance setting in a PHP stack. Done wrong — or left at defaults — it's a time bomb.
Why Docker Makes the Default Settings Worse
The PHP OPcache defaults were designed for a world where you have one long-running PHP-FPM process on a dedicated server with a known, stable set of files. Docker changes the assumptions:
- Your container's file count is predictable and fixed at build time. There's no code deployment happening inside a running container (or there shouldn't be). But the defaults still treat file existence as uncertain.
opcache.validate_timestamps=1by default means PHP checks every file's mtime on every request (or everyrevalidate_freqinterval). In production containers where code never changes at runtime, this is pure waste.memory_consumption=128(MB) by default was fine for smaller apps. A modern Laravel app with a full vendor tree, Livewire, Filament, or similar packages will blow past that. When the cache fills up, OPcache doesn't crash gracefully — it just stops caching new scripts and starts recompiling them on every single request.max_accelerated_files=10000by default sounds like a lot until you runfind /var/www/html -name '*.php' | wc -lon your actual project.
Here's what I run to check a project before I set any values:
# Run this inside your container or on the deployed filesystem
find /var/www/html -name '*.php' | wc -l
Last month I ran this on a mid-size Laravel app with Filament, Spatie packages, and a few first-party packages. The number was 14,847. The default max_accelerated_files of 10,000 would have silently left almost 5,000 files uncacheable.
The Configuration I Actually Deploy
I ship a opcache.ini file into the container at build time. No guessing at runtime, no environment variable stitching for these values — they're structural to how the app is deployed.
; /usr/local/etc/php/conf.d/opcache.ini
opcache.enable=1
opcache.enable_cli=0
; Memory for the opcode store. Measure your app first.
; Formula: (file_count * avg_script_size_kb) with 25% headroom
; For a mid-size Laravel/Filament app, 256MB is usually the right floor.
opcache.memory_consumption=256
; String interning pool. Deduplicated strings shared across scripts.
; 16MB is fine for most apps; bump to 32MB if you have a lot of translation files.
opcache.interned_strings_buffer=16
; Must be higher than your actual PHP file count. Round up to the next
; value in this set: 3907, 7963, 16229, 32531 (prime numbers work best).
opcache.max_accelerated_files=16229
; In Docker, code doesn't change at runtime. Disable timestamp validation.
; This is the biggest single perf win in a container environment.
opcache.validate_timestamps=0
; How aggressively to reuse memory. 1 = don't free memory until restart.
; In containers that's fine — they restart on deploy anyway.
opcache.revalidate_freq=0
; Pre-warm the cache at startup (PHP 7.4+). Point this at your preload script.
opcache.preload=/var/www/html/opcache-preload.php
opcache.preload_user=www-data
; Save compiled scripts to disk so cache survives PHP-FPM restarts
; within a running container (optional but nice).
; opcache.file_cache=/tmp/opcache
; opcache.file_cache_only=0
opcache.protect_memory=0
opcache.enable_file_override=0
And the preload script, which Laravel makes easy:
<?php
// opcache-preload.php
// Preloads all compiled classes into shared memory at PHP-FPM startup.
// This means the first request to each worker hits a warm cache.
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator('/var/www/html/vendor', RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($files as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
opcache_compile_file($file->getPathname());
}
}
Preloading adds 2-4 seconds to container startup time on a typical Laravel app. Worth it — your first request after a deploy no longer pays the compilation tax.
The Gotchas That Have Bit Me
validate_timestamps=0 will burn you in local dev. I've seen developers copy production INI files into dev containers and then spend an hour debugging why their code changes aren't taking effect. Keep a separate opcache-dev.ini with validate_timestamps=1 and revalidate_freq=0 for local.
OPcache has no graceful degradation when memory is full. This is the one that kills me. When memory_consumption is exhausted, PHP doesn't log a warning at request time. It just compiles scripts fresh and keeps serving. You won't know it's happening unless you're actively monitoring opcache_get_status(). I add a health-check endpoint to every Laravel app I ship:
// routes/web.php (restrict to internal/monitoring IPs in your middleware)
Route::get('/healthz/opcache', function () {
$status = opcache_get_status(false);
$full = $status['memory_usage']['free_memory'] < (5 * 1024 * 1024); // under 5MB free
$hitRate = $status['opcache_statistics']['hits'] /
max(1, $status['opcache_statistics']['hits'] + $status['opcache_statistics']['misses']);
return response()->json([
'enabled' => $status['opcache_enabled'],
'memory_free' => round($status['memory_usage']['free_memory'] / 1048576, 2) . ' MB',
'memory_used' => round($status['memory_usage']['used_memory'] / 1048576, 2) . ' MB',
'hit_rate' => round($hitRate * 100, 2) . '%',
'cached_scripts'=> $status['opcache_statistics']['num_cached_scripts'],
'cache_full' => $status['cache_full'],
'warning' => $full ? 'OPcache memory nearly exhausted' : null,
]);
});
Hit that endpoint after deploy and make sure cache_full is false and your hit rate is above 95% after a few minutes of traffic. If it's not, your memory_consumption needs to go up.
max_accelerated_files must be a prime number (or close to one) for optimal hash table performance. The PHP docs don't make this obvious. The internal structure uses a hash table sized to the next prime above your setting. Picking your own prime avoids the guesswork. The ones I use in production: 7963, 16229, 32531.
Preloading and Octane interact in non-obvious ways. If you're running Laravel Octane with Swoole or RoadRunner, preloading behavior changes significantly — Octane does its own class loading and the preload script can cause conflicts. I've disabled opcache.preload on Octane deployments and just let the persistent worker model handle warming naturally.
File cache (opcache.file_cache) sounds great, rarely worth it in containers. It writes serialized bytecode to disk so cache survives PHP-FPM restarts without a full recompile. But in a container, a restart usually means a new image with new code. The file cache will be stale and PHP will recompile everything anyway. It adds complexity for minimal real-world benefit in an immutable infrastructure model.
When I'd Reach for This (and When I Wouldn't)
Every production Laravel app in a container gets this treatment. Full stop. The lift is low — one INI file added to the Dockerfile, a preload script, a healthcheck route. The payoff is real: I consistently see 30-50% reduction in average response time moving from defaults to a properly tuned OPcache on apps that were already running with OPcache nominally enabled.
For local development, I keep OPcache enabled but with validate_timestamps=1. The compilation overhead in dev is negligible and I'd rather have accurate behavior than fast behavior when I'm iterating.
For CLI scripts and queue workers, opcache.enable_cli=0 in most cases. Queue workers are long-running processes and OPcache would help, but the memory overhead per worker adds up fast if you're running 20 workers and allocating 256MB of shared memory. Monitor and decide based on your worker count and available RAM.
If you're on a managed platform like Laravel Vapor or Fly.io's managed PHP, some of this is abstracted away — but you can still usually drop a custom php.ini or opcache.ini into the container build. I do it regardless, because I want to know exactly what's running.
The Bottom Line
OPcache tuning isn't glamorous work, but it's the kind of thing that separates a system that holds up at 3x normal load from one that doesn't. The defaults were written for a different deployment model, and running them unchanged in a Docker container is a quiet bet that your traffic will never spike at an inconvenient time. I stopped making that bet after watching a client's checkout flow go sideways because 128MB wasn't enough for their holiday catalog. Measure your file count, set your memory with headroom, disable timestamp validation in production, and add the healthcheck. Thirty minutes of work, meaningful results.
Need help shipping something like this? Get in touch.