Skip to content
intermediate Phase 15 · Performance and Deployment

Enabling OPcache

Cache compiled scripts to avoid re-parsing on every request.

35m
0 problems
Topic Progress 0%

Why One Laravel Request Compiles 300 Files Before It Runs a Line

A route on staging answers in 61 ms. Your controller's own code, timed end to end, takes 4 ms. The database round trip is 3 ms. Where do the other 54 milliseconds go? Without OPcache, they go into work you never see: PHP re-reads, re-tokenizes, and re-compiles every included file into opcodes on every single request. A stock Laravel application pulls in roughly 300 files from vendor plus your own app code, and the compiler runs through all of them before your first statement executes.

PHP's execution model is the reason this repeats. Each request boots an interpreter, loads files, compiles them to opcodes, executes, and throws everything away when the request ends. OPcache changes the last step: compiled opcodes live in a shared memory segment that every PHP-FPM worker in the pool maps, so a file is compiled once per version instead of once per request.

Stage What happens Without OPcache With OPcache
Parse Read file, tokenize Every request First request only
Compile Opcodes + literal tables Every request First request only
Execute Run the opcodes Every request Every request

The distinction interviewers listen for: OPcache caches compiled opcodes, never results. It cannot skip a query, a loop, or an HTTP call — it only deletes the compile step from every request after the first.

Enabling it is a handful of php.ini lines:

opcache.enable = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000

memory_consumption is the shared segment size in megabytes — it holds the compiled representation of every script your app loads. max_accelerated_files must exceed your distinct PHP file count; count it honestly with find . -name '*.php' | wc -l from the project root (vendor included).

Verify before you benchmark, because a silently disabled OPcache looks exactly like no OPcache at all:

php -v                      # look for "with Zend OPcache"
php --ri opcache | head -20 # runtime config + cache enabled

From inside the application, opcache_get_status() returns cached_scripts and memory_usage — the two numbers that tell you whether the cache is holding your whole app or evicting files (Chapter 3).

Then measure, because "it feels faster" is not evidence. Run ab -n 2000 -c 20 or wrk -t4 -c50 -d30s against one route with OPcache off and on. Compile-bound framework routes routinely improve 3x to 8x in throughput; pure-procedural scripts that load three files barely move, which is exactly why the win scales with your app's size. You came from Running and Monitoring Workers, where worker processes already amortize boot cost across jobs — OPcache is the equivalent amortization for short-lived web requests, and the first thing any interviewer expects to hear when a PHP app is called slow. Two mechanics explain why the win comes free. The shared segment is created once by the first worker; every later worker in the pool maps the same memory, so a file compiled by worker 1 is instantly available to workers 2 through 50 — no coordination, no locks on the read path. And nothing about your code changes: cached opcodes are byte-identical to the un-cached compile, so behavior is exactly the same, just sooner. What OPcache does not accelerate deserves one sentence in any answer: query time, external HTTP calls, and template logic that actually runs stay untouched — the compile step is the only cost it deletes, which is why a route loading three files gains little while a framework route gains everything.

One default surprises people: opcache.enable_cli = 0. php artisan migrate and one-shot console commands compile their files and exit, so they gain nothing — but a long-running CLI consumer compiles its files once on first use and keeps them, which is usually enough without touching the CLI flag.

validate_timestamps and the Deploy Trap: Fresh Code Versus a Stat Per File

OPcache caches compiled opcodes, so it must answer one question: when you deploy new code, how does the cache find out? Two settings decide, and the production answer trades freshness for speed.

With opcache.validate_timestamps = 1 (the default), each cached file's modification time is checked before it is served. opcache.revalidate_freq controls how often: 2 means each file is stat()-ed at most once every two seconds; 0 means every request stats every file. On a framework app that is thousands of stat calls per request. Over a network filesystem where stat costs real milliseconds, this is measurable; on local disk it is a small but constant tax.

Three environments, three settings — there is no universally correct value, only a correct value per environment:

Environment validate_timestamps revalidate_freq Why
Local dev 1 0 Edits appear instantly
Staging 1 15 Fresh within seconds, few stats
Production 0 n/a Zero stats; deploys reload FPM

Path resolution has a cache of its own: realpath_cache_size (default 4096 entries) and realpath_cache_ttl (120 seconds). After a symlink swap, long-running CLI processes can hold stale paths, which is why clearstatcache(true, $file) exists to force one path's re-resolution — web workers never need it because a reload flushes everything at once.

The production configuration flips the trade:

opcache.validate_timestamps = 0   ; PHP never checks the files again
opcache.revalidate_freq = 60      ; irrelevant while the line above is 0

With validate_timestamps = 0, running code is guaranteed to be the code that was compiled — zero stat calls, maximum throughput, and one sharp consequence: workers keep executing the old opcodes until the process restarts. Your deploy therefore has a required final step. Reload FPM gracefully and new workers start with a cold cache that compiles the new files:

sudo systemctl reload php8.3-fpm   # old workers drain, new workers compile fresh code

Calling opcache_reset() from a script is the weaker alternative: the reset only clears the cache of the worker that executed the call, so one HTTP hit cannot reach every process in the pool. The reload clears them all.

The real danger is not the reload — it is pairing validate_timestamps = 0 with an in-place deploy like git pull inside the web root. A request arriving mid-deploy can compile a mixture of old and new files, cache that mixture, and serve it until the next reload. The fix is atomic releases: extract into releases/2026-10-13-1420, then flip a current symlink in one rename(2) call so every file appears or disappears in a single instant, then reload FPM. Every file the compiler sees now belongs to exactly one version.

Two related knobs round out the picture. realpath_cache_size (default 4096 entries) and realpath_cache_ttl cache path resolution, which interacts with symlinks and long-running processes — CLI consumers that watch for new releases may need clearstatcache() after a symlink swap. And opcache.file_cache points at a disk fallback directory, so after a restart every worker does not recompile from scratch simultaneously; it costs a little cold-start latency and softens the stampede.

Environments differ, so do not copy settings blindly: staging with validate_timestamps = 1 and revalidate_freq = 15 lets a developer's git pull show up in seconds, while production stays at 0 behind atomic deploys. The interview question hides inside the trade: "How do you deploy PHP without restarting FPM?" The honest answer is that you cannot get fresh code with validate_timestamps = 0 — you either stat files, restart workers, or call resets everywhere, and the first option you pick reveals what you value.

OPcache Under Pressure: Full Buffers, Silent Evictions, and the JIT Nobody Needed

OPcache fails quietly. There is no exception, no 500 page, and no log line when the cache cannot do its job — throughput simply slides back toward the no-cache baseline. Three failure modes share that property, and one more tempts people into the wrong fix.

Interned strings. String literals in your code — class names, array keys, error messages — are deduplicated into opcache.interned_strings_buffer so identical strings share a single allocation. That buffer defaulted to 8 MB; PHP 8.2 raised it to 16 MB. When it fills, nothing errors: new strings are allocated like ordinary heap data, dedup stops, and FPM worker memory creeps upward. The symptom you will actually notice is resident memory on workers with a stable request mix.

Cached-script eviction. When memory_consumption or max_accelerated_files runs out, OPcache silently stops caching the overflow. Check opcache_get_status(): if cached_scripts is pinned at max_accelerated_files, or memory_usage.wasted_memory sits above a few percent, part of your app recompiles forever. Sizing rule: measure memory_usage / cached_scripts for the average compiled script, then set memory_consumption to files × average × 1.3 headroom. Worked example: a 4,000-file Laravel app at ~6 KB per compiled script needs roughly 4000 × 6 KB × 1.3 ≈ 31 MB, so the 128 MB default has room to spare; a 30,000-file monorepo does not fit that default at all and must be sized explicitly.

Shared-memory restart. On exhaustion OPcache restarts the cache and every worker recompiles at once — you will see "Restarting Opcache due to an error" or "out of memory" in the FPM log and a latency cliff on the graph. Oversizing memory is cheaper than a restart storm at peak traffic.

Measuring the pressure is one call away: opcache_get_status() returns memory_usage (used, free, wasted), interned_strings_usage (used, free, number_of_strings), and opcache_statistics — including the cache hit rate and oom_restarts, a counter of memory-triggered restarts. A healthy production pool shows a hit rate above 99.5% once warm and oom_restarts pinned at zero; if that counter climbs, memory_consumption is too small and every increment is a latency cliff paid by all workers simultaneously.

The CLI interaction that rounds out the picture: a queue worker started before a deploy holds its own opcode cache for its entire lifetime, so releases must restart long-running php artisan queue:work processes through supervisor exactly like FPM reloads — the same staleness rule, applied to a different process model. Saying that unprompted turns an OPcache answer into a deploy answer.

The JIT trap. opcache.jit = tracing with opcache.jit_buffer_size = 128M compiles hot paths to machine code. It helps numeric loops and parsers; it does nothing for a request dominated by SQL round trips or HTTP calls, which describes most web traffic. Turning on JIT to rescue an N+1 query is the wrong tool — add the missing eager load, or read Indexing and Query Tuning where the real latency lives.

A compact health checklist, runnable on any box:

php --ri opcache | grep -E 'opcache_mem|cached_scripts|wasted'
php -r 'var_export(opcache_get_status(false)["memory_usage"]);'

For interviews, the two questions that separate candidates: what exactly does OPcache cache (compiled opcodes — not query results, not route responses, not session data), and how does it know a file changed (mtime stat controlled by validate_timestamps and revalidate_freq, disabled entirely in production in favor of reloads). Both are answered above; the second one is where the deploy story from Chapter 2 earns its keep.

Quiz

1. A production deploy replaces files on disk, but workers keep executing the old code indefinitely. The production php.ini sets opcache.validate_timestamps = 0. What correctly completes the deploy?

Question 1 options

2. opcache.interned_strings_buffer fills up on a large application. What happens next?

Question 2 options

3. You run `php --ri opcache` and see cached_scripts stuck exactly at max_accelerated_files while throughput has dropped. What is the most accurate interpretation?

Question 3 options

4. Which statement about OPcache and CLI processes is accurate?

Question 4 options

Flashcards

Question

What exactly does OPcache cache, and what does it never cache?

Answer

Compiled opcodes: the parse-and-compile output for each PHP file, stored in a shared memory segment mapped by every FPM worker. It never caches results — query outcomes, rendered responses, or session data are untouched; the compile step is the only cost it removes.

Question

What do opcache.memory_consumption, opcache.interned_strings_buffer, and opcache.max_accelerated_files each size?

Answer

memory_consumption is the shared segment holding compiled scripts; interned_strings_buffer deduplicates string literals (8 MB default, 16 MB since PHP 8.2); max_accelerated_files caps how many distinct scripts the cache can hold — exceed it and the overflow recompiles every request, silently.

Question

What is the cost of opcache.validate_timestamps = 1 with revalidate_freq = 0, and how does production avoid it?

Answer

Every request stats every cached file to check mtimes — thousands of syscalls per request. Production sets validate_timestamps = 0, eliminating all stat calls, and accepts that fresh code requires a php-fpm reload (or per-worker opcache_reset calls) after an atomic deploy.

Question

Why is an in-place deploy dangerous with validate_timestamps = 0, and what replaces it?

Answer

A request can compile a mix of old and new files mid-deploy and cache that mixture indefinitely. Atomic releases fix it: extract to releases/<timestamp>, flip a current symlink in one rename() so all files change at once, then reload FPM.

Question

When does the OPcache JIT help a web application, and when is it the wrong fix?

Answer

It helps hot numeric loops and parsers — sustained CPU-bound code with predictable branches. It does nothing for requests dominated by SQL, HTTP, or I/O wait, so enabling JIT to rescue a slow endpoint whose time is in the database (an N+1 query, a missing index) fixes nothing while costing memory and warm-up.

Revision Notes

Key Takeaways

  • 1. OPcache caches compiled opcodes in shared memory, deleting the parse/compile step from every request — the win scales with how many files your framework loads, not with how complex your queries are
  • 2. memory_consumption, interned_strings_buffer, and max_accelerated_files each fail silently: eviction, lost string dedup, or recompiles, visible only through opcache_get_status()
  • 3. validate_timestamps = 0 removes every stat call but pins old opcodes in memory, so deploys must be atomic (symlink swap) and finish with a graceful php-fpm reload
  • 4. opcache_reset() clears one worker's cache — a single HTTP call cannot flush a pool; the reload is the reliable hammer
  • 5. JIT helps numeric hot loops, not SQL- or HTTP-bound requests — a slow endpoint usually needs query tuning, not machine code

Interview Tips

  • • "Why is PHP slow without OPcache?" — say each request re-parses and re-compiles every file, cite ~300 files in a Laravel app, then state that OPcache caches opcodes, not results
  • • "How do you deploy without breaking OPcache?" — atomic releases plus FPM reload with validate_timestamps = 0; mention why revalidate_freq=0 (stat per request) is the alternative, not a free lunch
  • • "OPcache is enabled but nothing got faster — what do you check?" — php --ri opcache for enabled state, then cached_scripts vs max_accelerated_files, wasted_memory, and whether the traffic is compile-bound at all (CLI, tiny apps, I/O-bound routes)
  • • "When would you enable the JIT?" — hot numeric loops and parsers; for a web request waiting on MySQL or an API call, the JIT changes nothing and costs memory

Cheat Sheet

Enabling OPcache

Baseline config:

opcache.enable = 1
opcache.memory_consumption = 128        ; MB for compiled scripts
opcache.interned_strings_buffer = 16    ; MB for deduplicated string literals (8 default pre-8.2)
opcache.max_accelerated_files = 10000   ; > distinct PHP files incl. vendor
opcache.validate_timestamps = 0         ; production: never stat files

Verify: php -v (with Zend OPcache) → php --ri opcache → opcache_get_status() for cached_scripts and memory_usage.

Deploys with validate_timestamps = 0: atomic release (releases/ + symlink swap) → systemctl reload php8.3-fpm. Never git pull in place; opcache_reset() only clears the calling worker.

Sizing: memory_consumption ≥ files × (memory_usage/cached_scripts) × 1.3. Watch wasted_memory and cached_scripts == max_accelerated_files.

JIT: opcache.jit = tracing + jit_buffer_size = 128M — CPU-bound loops only; irrelevant to I/O-bound requests.

Continue Learning