OPcache and JIT: Caching, Configuration, and Just-In-Time Compilation
Understand what OPcache actually caches and why it matters, configure it correctly for both development and production (memory sizing, timestamp validation, invalidation, preloading), understand what PHP's JIT does and its real-world limitations for typical I/O-bound web workloads, and know how to measure before tuning either one.
Of everything covered in the two previous internals lessons, OPcache and JIT are the two levers that actually show up in a production php.ini file and actually move real request latency numbers -- which also makes them the two things most likely to get tuned based on guesswork rather than measurement. This lesson is deliberately practical: real configuration directives, what each one actually controls, the single most common OPcache production mistake (serving stale bytecode after a deploy), and an honest account of what JIT can and can't be expected to speed up for a typical PHP web application versus a CPU-bound script.
What OPcache Actually Caches, and Why It Matters
As covered in the Zend Engine lesson, every PHP file has to be lexed, parsed, and compiled into Zend opcodes (a zend_op_array) before it can execute -- and without OPcache, that entire pipeline runs fresh on every single request, for every single file the request touches, discarding the compiled result the instant the request finishes. For a framework bootstrapping hundreds of class files per request, that's real, repeated, wasted CPU work happening on every request, all producing the identical compiled output every time (the source hasn't changed between requests).
OPcache is a bundled Zend extension that stores each file's already-compiled zend_op_array in a block of shared memory, keyed by file path, and shared across every PHP-FPM worker process on that machine. On a cache hit, a request skips lexing, parsing, and compiling entirely and goes straight from 'found in cache' to execution -- this is, in most real-world PHP applications, the single highest-impact performance setting available, often cutting response times dramatically simply by eliminating redundant compilation work that was never actually necessary to repeat.
This is also why OPcache being disabled (or its shared memory pool being too small to hold your whole codebase, causing constant eviction and 'cache full' restarts) is one of the first things to check when a PHP application feels unexpectedly slow -- it's rarely a subtle bug, and very often simply means every request is paying the full compile cost that OPcache exists specifically to eliminate.
• OPcache caches compiled zend_op_array structures in shared memory, keyed by file path, shared across all workers. • A cache hit skips lexing, parsing, AND compiling entirely -- straight to execution. • This is usually the single highest-impact PHP performance setting -- check it first when something feels slow. • A too-small memory pool causes eviction/'cache full' restarts, silently undermining the whole benefit.
Core Configuration and Shared Memory
opcache.enable (and opcache.enable_cli separately, since CLI scripts default to not using OPcache unless explicitly told to) turns caching on. opcache.memory_consumption (in MB) sizes the shared memory pool that holds all cached opcodes across every file OPcache tracks -- too small, and OPcache starts evicting already-cached files to make room for new ones, which under load can mean files get compiled, evicted, and recompiled repeatedly, actively working against you instead of helping.
opcache.max_accelerated_files caps how many distinct files OPcache will track at once; if your codebase (including every vendor dependency file that actually gets included/required) has more PHP files than this limit, the same eviction-and-thrash problem occurs even if raw memory would otherwise be plenty -- large modern frameworks with deep dependency trees routinely need this raised well above its historical default.
opcache.interned_strings_buffer sizes a separate shared memory pool specifically for interned strings (PHP deduplicates identical string literals like class/function/constant names into one shared copy) -- undersizing this doesn't break anything, but it does silently reduce a memory-deduplication benefit you'd otherwise get essentially for free across all worker processes.
• opcache.enable / opcache.enable_cli -- turn caching on for FPM/web and CLI SAPIs respectively. • opcache.memory_consumption (MB) -- shared memory pool size; too small causes eviction/thrashing. • opcache.max_accelerated_files -- cap on distinct tracked files; must exceed your real file count, vendor included. • opcache.interned_strings_buffer -- separate pool for deduplicated string literals shared across workers.
Invalidation and the Most Common Production Mistake
opcache.validate_timestamps, when on (the sensible default for local development), makes OPcache check a cached file's modification time against the file on disk -- at most once every opcache.revalidate_freq seconds, not on every single request -- and automatically recompile it if the file changed. This is what lets you edit a file locally and see the change on your very next request (or within a few seconds) without doing anything special.
In production, the standard advice is to set opcache.validate_timestamps=0, which disables that check entirely -- once a file is cached, OPcache will keep serving the cached bytecode forever, never re-checking the filesystem, which removes even that small per-request/per-interval filesystem stat overhead. This is a genuine, worthwhile optimization -- but it comes with an obligation: with timestamp validation off, deploying new code to disk changes nothing for already-cached files until the cache is explicitly invalidated.
This is, by a wide margin, the single most common OPcache-related production incident: a team deploys a bug fix, the fix doesn't take effect, and confusion follows -- because validate_timestamps=0 means the old, cached bytecode is still what's actually running. The fix has to be part of the deploy process itself: either restart PHP-FPM (which clears its in-memory OPcache), or call opcache_reset()/opcache_invalidate($file, force: true) for the specific changed files, as an explicit deploy step -- not an afterthought discovered during an incident.
• opcache.validate_timestamps=1 -- checks mtime at most every revalidate_freq seconds, auto-recompiles changed files. • opcache.validate_timestamps=0 (typical production setting) -- never checks the filesystem; maximum performance. • With validate_timestamps=0, a deploy does NOT take effect until the cache is explicitly reset -- this is the #1 OPcache production footgun. • Fix: restart PHP-FPM, or call opcache_reset()/opcache_invalidate() as an explicit, automated step in the deploy process itself.
Preloading
Preloading (opcache.preload, pointing at a designated PHP script) takes caching one step further: at server startup, before any request is served, that script runs once and can explicitly compile and load specific classes and functions into a permanent, always-resident area of memory shared by every worker for the entire lifetime of the server process -- not just cached for reuse on a hit, but genuinely always loaded, bypassing even the normal per-request OPcache lookup.
This is the biggest available win for large, stable core code that's used on essentially every single request -- a framework's kernel, its container, its base classes -- because it means that code never needs to be found-and-loaded from the cache at all during request handling; it's simply already there. The tradeoff is permanence within that server's process lifetime: preloaded code cannot be reloaded or invalidated without restarting the entire server, which makes preloading appropriate only for genuinely stable files, not application code that changes often (and definitely not during active local development).
Because preloaded classes are loaded once, globally, before any request-specific context exists, code intended for preloading has to avoid assumptions that don't hold true at that point -- it generally shouldn't depend on request-specific state, and classes preloaded this way become available automatically in every request without any include/require/autoload needed for them specifically.
• opcache.preload -- a script that runs once at server startup, loading specific classes/functions permanently. • Preloaded code skips even the normal per-request OPcache lookup -- it's simply always resident in memory. • Best for stable, framework-core-style code used on nearly every request; requires a full server restart to update. • Not appropriate for frequently-changing application code, or during active local development.
JIT: What It Does and Its Real Limitations
PHP's JIT (Just-In-Time compiler), built on top of OPcache's shared memory infrastructure since PHP 8.0, takes selected Zend opcode sequences and compiles them directly into native machine code, letting the CPU run that code directly instead of the Zend VM's fetch-dispatch-execute interpreter loop processing each opcode through its C handler function one at a time. It supports different operating modes (commonly grouped as 'function' JIT, compiling whole functions ahead of a threshold of calls, and 'tracing' JIT, which follows actually-executed hot paths through the code, including through branches, and compiles those specific traces) -- tracing JIT is the default and generally the stronger real-world performer of the two.
The honest, important limitation: most typical PHP web application request handling is not CPU-bound in the way a tight numeric loop is. A typical web request spends the overwhelming majority of its wall-clock time waiting on I/O -- database queries, external HTTP calls, filesystem reads, template rendering that's itself mostly string concatenation and function-call overhead rather than raw computation. JIT speeds up the CPU-bound portion of execution (the actual opcode interpretation loop); it cannot make a database query, a network round trip, or a disk read finish any faster, because none of that time was ever spent in PHP's own opcode execution to begin with.
Where JIT reliably shows large, measurable gains is genuinely CPU-bound PHP code: tight numeric loops, image/data processing, cryptographic operations, parsing large amounts of data purely in PHP, ray-tracing-style benchmarks -- code that spends most of its time actually executing opcodes rather than waiting on something external. For a typical CRUD web application, JIT is worth testing (it's rarely harmful) but should be expected to produce a modest improvement at best, not the dramatic gains often reported for CPU-bound micro-benchmarks; treating those benchmark numbers as a promise for your specific I/O-bound app is the most common JIT-related disappointment.
• JIT compiles opcode sequences into native machine code, bypassing the VM's interpret loop for that code. • Tracing JIT (the default) follows actually-hot execution paths; function JIT compiles whole functions past a call threshold. • JIT speeds up CPU-bound opcode execution only -- it cannot speed up time spent waiting on a database, network, or disk. • Big gains: CPU-bound code (numeric loops, image/data processing). Modest gains: typical I/O-bound CRUD web requests.
Measuring Before Optimizing
opcache_get_status() is the first stop before tuning anything: it reports whether OPcache is enabled, the number of cached scripts, hit/miss counts, current memory usage within the shared memory pool, and -- critically -- the number of 'restarts' (forced cache flushes, often triggered by running out of memory or hitting max_accelerated_files). A high miss rate or frequent restarts is the actual, measured justification for raising memory_consumption or max_accelerated_files; guessing at bigger numbers without checking this first is tuning blind.
Before enabling or tuning JIT specifically, profile the actual workload (a proper profiler, or even just wall-clock timing around the suspected hot path) to establish whether the code in question is genuinely CPU-bound or I/O-bound. Turning JIT on for an application whose request time is dominated by waiting on a slow database query and expecting a dramatic overall improvement is a measurement failure, not a JIT failure -- the bottleneck was never in PHP's own opcode execution to begin with, so no amount of native-code compilation of that opcode execution touches the actual bottleneck.
The general discipline -- measure the current state, identify where time/memory is actually going, change one thing, measure again -- applies as much to OPcache/JIT configuration as it does to application code. A production php.ini worth trusting is one whose settings were arrived at by checking opcache_get_status() and real profiling data, not by copy-pasting a 'recommended settings' block without verifying it fits this specific application's actual file count and workload shape.
• opcache_get_status() -- check hit rate, restart count, and memory usage before changing memory_consumption or max_accelerated_files. • Frequent restarts or a high miss rate is the real justification for raising memory limits -- not a guess. • Profile before enabling JIT: is the actual bottleneck CPU-bound opcode execution, or I/O wait? • Change one setting at a time and re-measure -- don't copy a 'recommended' config blindly without checking it fits your workload.
A Realistic Production OPcache/JIT php.ini Block
validate_timestamps=0 is a deliberate tradeoff: max performance, but deploys must explicitly invalidate the cache. opcache.jit_buffer_size must be set (nonzero) or JIT stays effectively disabled regardless of opcache.jit. opcache.preload runs once at startup -- changes to the preloaded files require a full server restart to take effect.
; --- OPcache: core ---
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; --- OPcache: production invalidation strategy ---
; No filesystem checks at all -- deploys MUST call opcache_reset()
; or restart PHP-FPM as an explicit step, or changes won't take effect.
opcache.validate_timestamps=0
; --- Preloading (stable framework core only) ---
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-data
; --- JIT (built on top of OPcache's buffer) ---
opcache.jit_buffer_size=64M
opcache.jit=tracingCode Examples
revalidate_freq=0 means OPcache checks each file's modification time on every request rather than waiting an interval, so editing a file and reloading the page reflects the change immediately -- an intentional performance tradeoff that's completely appropriate at development traffic levels but would add needless filesystem overhead in production.
opcache.revalidate_freq=0 ; check the filesystem on every request
This is exactly the kind of check to run before touching any OPcache setting: a low hit rate, any oom_restarts, or a cached-script count near the configured max_accelerated_files are concrete, measured signals that memory or the file-count cap genuinely needs raising -- as opposed to changing those numbers speculatively without evidence. Expected result: Cached scripts: 8412 / 20000 max Hit rate: 99.87% Cache restarts: 0 (out-of-memory or hash-collision restarts) Memory used: 201326592 / 268435456 bytes
$hitRate = $stats['hits'] / ($stats['hits'] + $stats['misses']) * 100;
With validate_timestamps=0, neither opcache_invalidate() for individual files nor a full opcache_reset() happens automatically -- one of them has to be an explicit, scripted step in the deploy pipeline itself, run against every PHP-FPM worker (or triggered via a restart), or the previous version's bytecode keeps serving indefinitely after a deploy. Expected result: /var/www/app/src/Billing/InvoiceService.php: invalidated /var/www/app/src/Billing/PaymentGateway.php: invalidated Full OPcache reset complete.
Common mistake: Setting opcache.validate_timestamps=0 in production without adding any cache-invalidation step to the deploy process -- the most common way teams end up debugging 'my fix isn't taking effect' incidents that are actually just stale cached bytecode.
A tight numeric loop like primality testing spends effectively all of its time inside PHP's own opcode execution -- exactly the portion JIT compiles into native machine code -- so it's one of the clearest real examples of a genuinely CPU-bound PHP workload where enabling JIT produces a measurable difference, unlike a typical request dominated by a database round trip. Expected result: Primes below 2,000,000: 148933 Elapsed: 1.842s (this is exactly the kind of tight, CPU-bound loop JIT meaningfully speeds up) for ($i = 2; $i * $i <= $n; $i++) { if ($n % $i === 0) { return false; } }
; Local development: prioritize seeing changes instantly over raw speed.
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
opcache.validate_timestamps=1
opcache.revalidate_freq=0 ; check the filesystem on every request -- fine for dev traffic levels<?php
declare(strict_types=1);
if (!function_exists('opcache_get_status')) {
echo "OPcache is not available.\n";
exit(1);
}
$status = opcache_get_status(fetch_scripts: false);
if ($status === false || !$status['opcache_enabled']) {
echo "OPcache is installed but not enabled.\n";
exit(1);
}
$stats = $status['opcache_statistics'];
$hitRate = $stats['hits'] + $stats['misses'] > 0
? $stats['hits'] / ($stats['hits'] + $stats['misses']) * 100
: 0.0;
printf("Cached scripts: %d / %d max\n", $stats['num_cached_scripts'], ini_get('opcache.max_accelerated_files'));
printf("Hit rate: %.2f%%\n", $hitRate);
printf("Cache restarts: %d (out-of-memory or hash-collision restarts)\n", $stats['oom_restarts'] + $stats['hash_restarts']);
printf("Memory used: %d / %d bytes\n", $status['memory_usage']['used_memory'], $status['memory_usage']['used_memory'] + $status['memory_usage']['free_memory']);
if ($stats['oom_restarts'] > 0) {
echo "WARNING: out-of-memory restarts detected -- raise opcache.memory_consumption.\n";
}
if ($stats['num_cached_scripts'] >= (int) ini_get('opcache.max_accelerated_files') - 10) {
echo "WARNING: near max_accelerated_files -- ra
…<?php
declare(strict_types=1);
// deploy/invalidate-opcache.php -- run this as a deploy step whenever
// opcache.validate_timestamps=0 is set in production.
$changedFiles = [
'/var/www/app/src/Billing/InvoiceService.php',
'/var/www/app/src/Billing/PaymentGateway.php',
];
if (!function_exists('opcache_invalidate')) {
fwrite(STDERR, "OPcache is not available on this host.\n");
exit(1);
}
foreach ($changedFiles as $file) {
$invalidated = opcache_invalidate($file, force: true);
printf("%s: %s\n", $file, $invalidated ? 'invalidated' : 'no cached entry found');
}
// For a full deploy, resetting the entire cache is simpler and safer
// than tracking every individual changed file:
opcache_reset();
echo "Full OPcache reset complete.\n";<?php
declare(strict_types=1);
function isPrime(int $n): bool
{
if ($n < 2) {
return false;
}
for ($i = 2; $i * $i <= $n; $i++) {
if ($n % $i === 0) {
return false;
}
}
return true;
}
$start = microtime(as_float: true);
$primeCount = 0;
for ($n = 2; $n < 2_000_000; $n++) {
if (isPrime($n)) {
$primeCount++;
}
}
$elapsed = microtime(as_float: true) - $start;
printf("Primes below 2,000,000: %d\n", $primeCount);
printf("Elapsed: %.3fs (this is exactly the kind of tight, CPU-bound loop JIT meaningfully speeds up)\n", $elapsed);
// Contrast: a real request handler spending its time on a network call
// (e.g. an HTTP request to a payment gateway, or a slow SQL query) has
// almost none of its wall-clock time inside PHP's own opcode execution --
// JIT has essentially nothing to speed up in that case.Compile Once, Reuse the Cached Opcode
Compile happens once per file (until invalidated); every subsequent request for that file goes straight from OPcache to Execute -- optionally via JIT-compiled native code for hot opcode sequences.
PHP Source
|
v
Compile (lex -> parse -> AST -> opcodes -- only on a cache MISS)
|
v
Opcode (zend_op_array for this file)
|
v
OPcache (stored in shared memory, keyed by file path)
|
+---- next request, same file ----+
| v
| Reuse Cached Opcode (skip Compile entirely)
v |
Execute <------------------------------+Important Rules
Always measure with opcache_get_status() (hit rate, restarts) before changing memory_consumption or max_accelerated_files.
In production, set opcache.validate_timestamps=0 for performance, but ALWAYS pair it with an explicit cache-invalidation deploy step.
Reserve opcache.preload for genuinely stable, framework-core-style code -- it requires a full server restart to update.
Profile before enabling/tuning JIT: it speeds up CPU-bound opcode execution, not I/O wait time.
Expect modest JIT gains for typical I/O-bound web requests; expect large gains only for genuinely CPU-bound PHP code.
Why This Matters in Production
OPcache misconfiguration is one of the most common causes of both 'this deploy didn't take effect' incidents (stale bytecode from validate_timestamps=0 with no invalidation step) and unexplained slow requests (OPcache disabled, or thrashing from an undersized memory_consumption/max_accelerated_files) in real PHP production environments -- and both are diagnosable in minutes with opcache_get_status() rather than guesswork. JIT is a genuinely useful tool for the right workload (background jobs doing heavy computation, image processing pipelines, data transformation scripts) and a low-value one for a typical CRUD API dominated by database round trips -- knowing which category your workload falls into, from actual profiling rather than assumption, is what separates a useful performance change from cargo-culted configuration.
opcache.validate_timestamps=0 in production, with deploys that only rsync new files to disk and nothing else opcache.validate_timestamps=0 in production, PLUS an automated deploy step calling opcache_reset() (or restarting PHP-FPM) immediately after files are updated
With timestamp validation off, OPcache never re-checks the filesystem on its own -- a deploy that only updates files on disk changes nothing about what's actually running until the cache is explicitly invalidated or the server restarts.
"We enabled JIT and our API response times barely changed -- JIT doesn't work."
Profile first: if request time is dominated by database queries and external HTTP calls (the common case for a typical API), JIT was never going to help, because it only speeds up PHP's own opcode execution, not time spent waiting on I/O.
JIT compiles opcode sequences into native machine code -- it has no effect on time spent blocked waiting for a database, network call, or disk I/O, which is where most of a typical web request's wall-clock time actually goes.
[multiple-choice] What is the most common production incident caused by OPcache misconfiguration? A. OPcache silently corrupts application data B. With opcache.validate_timestamps=0, a deploy's new code doesn't take effect until the cache is explicitly invalidated or the server restarts C. OPcache causes every request to run twice D. OPcache disables all PHP extensions automatically
B. With opcache.validate_timestamps=0, a deploy's new code doesn't take effect until the cache is explicitly invalidated or the server restarts — validate_timestamps=0 disables the filesystem mtime check entirely for performance -- which means a deploy that just updates files on disk has no effect on what's actually running until the cache is explicitly reset/invalidated, or PHP-FPM is restarted.
[multiple-choice] For a typical CRUD web API whose request time is mostly spent waiting on database queries, what should you expect from enabling JIT? A. A dramatic multi-times speedup on every request B. A modest improvement at best, since JIT only speeds up PHP's own opcode execution, not time spent waiting on the database C. No change is possible under any circumstances D. JIT will make the database queries themselves run faster
B. A modest improvement at best, since JIT only speeds up PHP's own opcode execution, not time spent waiting on the database — JIT compiles hot opcode sequences into native machine code, speeding up CPU-bound execution inside the Zend VM. It has no effect on time spent blocked waiting for I/O (database queries, network calls), which dominates most typical web request wall-clock time.
[multiple-choice] Before raising opcache.memory_consumption or opcache.max_accelerated_files, what should you check first? A. Nothing -- always raise both to the maximum possible value B. opcache_get_status(), looking specifically at hit rate, restart counts, and current memory usage, to confirm the change is actually justified C. Whether JIT is enabled D. The PHP version number
B. opcache_get_status(), looking specifically at hit rate, restart counts, and current memory usage, to confirm the change is actually justified — opcache_get_status() reports the real, measured signals (hit/miss rate, oom_restarts, cached script count vs the configured cap) that justify raising these limits -- tuning them without checking is guesswork, not measurement-driven optimization.
Your team just deployed a bug fix to production, but users report the bug is still happening. opcache.validate_timestamps is set to 0. Write out, step by step, how you would confirm this is a stale-OPcache issue and the exact fix you'd apply.
1) Confirm validate_timestamps=0 in the effective php.ini (ini_get('opcache.validate_timestamps')). 2) Check opcache_get_status()['scripts'][$file]['timestamp'] (or a full opcache_reset() test) against the file's actual on-disk mtime/content to see if the cached version predates the fix. 3) If confirmed, either restart PHP-FPM on every affected host, or call opcache_reset() (or targeted opcache_invalidate($file, force: true) calls) as an immediate remediation. 4) Add an explicit opcache_reset()/restart step to the deploy pipeline itself so this doesn't recur on the next deploy.
Think about what validate_timestamps=0 actually disables, and what opcache_get_status() or a direct opcache_invalidate()/opcache_reset() call would tell you or fix.