OPcache is the single most impactful PHP performance setting you can tune. Without it, PHP parses and compiles every script on every request. With a well-tuned OPcache, compiled bytecode is served from shared memory — no disk reads, no compilation. This guide covers every production-relevant directive, preloading for Drupal/frameworks, JIT configuration for PHP 8+, and the Docker-specific concerns that trip up most setups.
How OPcache Works
On the first request for a PHP file, Zend Engine compiles the source to bytecode and stores it in shared memory. Subsequent requests skip compilation entirely and execute the cached bytecode. OPcache sits between the PHP source file and the executor.
The JIT compiler (PHP 8.0+) goes further: it compiles hot bytecode paths to native machine code during execution. JIT helps CPU-bound workloads significantly; for typical I/O-bound web apps (Drupal, WordPress) the improvement is modest but nonzero.
Baseline Production Configuration
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
; For Drupal 11 / PHP 8.3 on a 4+ core server with 4 GB+ RAM
[opcache]
opcache.enable = 1
opcache.enable_cli = 0 ; Keep off unless running CLI-heavy workloads
; ── Memory ───────────────────────────────────────────────────────────────────
opcache.memory_consumption = 256 ; MB of shared memory for bytecode
opcache.interned_strings_buffer= 24 ; MB for interned strings (separate pool — does NOT come out of memory_consumption)
opcache.max_accelerated_files = 30000 ; Upper limit on cached scripts
; ── Timestamp Validation ─────────────────────────────────────────────────────
; Production: disable checking so no stat() calls per request.
; You must clear the cache after every deployment (see below).
opcache.validate_timestamps = 0
opcache.revalidate_freq = 0 ; Irrelevant when validate_timestamps=0
; ── Correctness Guards ───────────────────────────────────────────────────────
opcache.file_update_protection = 2 ; Don't cache files modified in last N seconds
; Required for Drupal annotations and Symfony docblocks:
opcache.save_comments = 1
; ── Robustness ───────────────────────────────────────────────────────────────
; If SHM fills up, fall back to per-process file cache instead of crashing
opcache.file_cache = /var/www/opcache
opcache.file_cache_fallback = 1
opcache.file_cache_only = 0
; ── JIT (PHP 8+) ─────────────────────────────────────────────────────────────
opcache.jit = tracing
opcache.jit_buffer_size = 128MCreate the file cache directory and set ownership:
mkdir -p /var/www/opcache
chown www-data:www-data /var/www/opcache
chmod 700 /var/www/opcacheSizing memory_consumption Correctly
Too small and OPcache silently evicts scripts under load. Too large and you waste RAM. Check actual usage after warming the cache:
<?php
// opcache-status.php — place outside webroot or behind auth
$status = opcache_get_status(false);
$config = opcache_get_configuration();
printf("Enabled: %s\n", $status['opcache_enabled'] ? 'yes' : 'no');
printf("Used memory: %.1f MB / %.1f MB (%.0f%%)\n",
$status['memory_usage']['used_memory'] / 1048576,
($status['memory_usage']['used_memory'] + $status['memory_usage']['free_memory']) / 1048576,
$status['memory_usage']['used_memory'] /
($status['memory_usage']['used_memory'] + $status['memory_usage']['free_memory']) * 100
);
printf("Cached files: %d / %d\n",
$status['opcache_statistics']['num_cached_scripts'],
$config['directives']['opcache.max_accelerated_files']
);
printf("Hit rate: %.2f%%\n", $status['opcache_statistics']['opcache_hit_rate']);
printf("OOM restarts: %d\n", $status['opcache_statistics']['oom_restarts']);
printf("Hash restarts:%d\n", $status['opcache_statistics']['hash_restarts']);- Hit rate below 99% in steady state → increase
memory_consumptionormax_accelerated_files - OOM restarts > 0 → OPcache ran out of memory; increase
memory_consumption - Hash restarts > 0 → too many scripts; increase
max_accelerated_files
Quick Sizing Formula
For Drupal 11: count PHP files in the project and multiply by ~5 KB average compiled size:
find /var/www/html -name '*.php' | wc -l
# Typical Drupal 11: 15,000–25,000 files
# 20,000 files × 5 KB = ~100 MB bytecode + headroom → set 256 MBClearing the Cache After Deployment
With validate_timestamps = 0, PHP never checks whether source files changed. You must clear OPcache as part of every deployment:
Option 1: PHP CLI (works when CLI uses same SHM)
php -r "opcache_reset();"This only works if the CLI PHP process shares the same shared memory segment as PHP-FPM, which is rarely the case. Use Option 2 instead.
Option 2: HTTP endpoint (most reliable)
<?php
// /var/www/html/opcache-reset.php — protect with IP restriction in Nginx
if (opcache_reset()) {
http_response_code(200);
echo "OPcache reset OK\n";
} else {
http_response_code(500);
echo "OPcache reset FAILED\n";
}# Call during deployment
curl -f http://127.0.0.1/opcache-reset.php || exit 1# Restrict to localhost only in Nginx
location = /opcache-reset.php {
allow 127.0.0.1;
deny all;
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}Option 3: PHP-FPM graceful reload
# Sends USR2 to master process — zero-downtime restart, clears OPcache
systemctl reload php8.3-fpm
# or in Docker:
docker compose exec php kill -USR2 1Preloading (PHP 7.4+)
Preloading compiles a set of PHP files into shared memory at PHP-FPM startup. Those files are available to every request with zero resolution overhead — not just cached bytecode, but fully linked classes.
opcache.preload = /var/www/html/preload.php
opcache.preload_user = www-data<?php
// preload.php — run once at FPM startup
// Preload frequently used Drupal core and Symfony classes
$files = [
// Symfony contracts (hot in every request)
__DIR__ . '/vendor/symfony/http-foundation/Request.php',
__DIR__ . '/vendor/symfony/http-foundation/Response.php',
__DIR__ . '/vendor/symfony/http-kernel/HttpKernelInterface.php',
__DIR__ . '/vendor/symfony/event-dispatcher/EventDispatcher.php',
// Drupal bootstrap
__DIR__ . '/web/core/lib/Drupal/Core/DrupalKernel.php',
__DIR__ . '/web/core/lib/Drupal/Core/Database/Connection.php',
];
foreach ($files as $file) {
if (file_exists($file)) {
opcache_compile_file($file);
}
}
// Or preload entire directories using a generated list from Composer
// $classMap = require __DIR__ . '/vendor/composer/autoload_classmap.php';
// foreach ($classMap as $class => $file) {
// if (str_starts_with($file, __DIR__ . '/vendor/symfony/')) {
// opcache_compile_file($file);
// }
// }Preloading constraints:
- Preloaded classes are available globally — they bypass the autoloader entirely
- The preload script runs as
opcache.preload_user; it must be set (cannot run as root) - Preloaded files cannot be invalidated without a full PHP-FPM restart
- Do not preload files that change between deployments (auto-generated Drupal caches)
JIT: When It Helps and When It Doesn't
JIT compiles hot bytecode to native x86-64 instructions. For typical Drupal web requests (dominated by database queries and I/O waits), JIT gains are in the 2–10% range. For pure PHP computation (image processing, data transformation, CLI batch jobs), gains can reach 50%+.
; Recommended for most web workloads
opcache.jit = tracing
opcache.jit_buffer_size = 128M
; For CLI-heavy compute workloads, try:
opcache.jit = 1255 ; More aggressive tracing
opcache.jit_buffer_size = 256MThe four-digit opcache.jit value encodes flags:
- Digit 1 (C): CPU optimization flags (1 = enable AVX/SSE)
- Digit 2 (R): Register allocation (2 = global)
- Digit 3 (T): JIT trigger (5 = tracing)
- Digit 4 (O): Optimization level (4 = maximum)
Named constants like tracing and function are aliases — use them for clarity.
JIT requires opcache.enable = 1. Disable JIT in development to get accurate line-level profiling from Xdebug (JIT and Xdebug profiling are mutually exclusive).
Docker-Specific Considerations
OPcache stores bytecode in POSIX shared memory (/dev/shm). In Docker, this defaults to 64 MB — far too small for a Drupal install. Increase it:
# docker-compose.yml
services:
php:
image: php:8.3-fpm
shm_size: '512mb' # Or use --shm-size with docker runAlternatively, switch OPcache to file-based memory mapping:
; Forces OPcache to use mmap() on a file instead of POSIX SHM
opcache.huge_code_pages = 0 ; Disable huge pages in containers (not available)
opcache.file_cache_only = 0 ; Keep SHM as primary, file as fallbackIn ephemeral containers where the filesystem is discarded between deployments, the file cache directory (opcache.file_cache) must be on a volume or omitted:
services:
php:
volumes:
- opcache_data:/var/www/opcache
volumes:
opcache_data: {}validate_timestamps in Development
In development you want changes to take effect immediately:
; Development override — add as a separate file or Docker env
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0 ; Check every request
opcache.jit = off ; Faster recompilation without JITIn Docker, override via environment variable (PHP 8 respects PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 with the official images' entrypoint scripts — verify your image's behaviour).
Summary Checklist
- Set
opcache.memory_consumptionbased on actual file count — start at 256 MB for Drupal - Set
opcache.interned_strings_buffer = 24— this is a separate memory pool frommemory_consumption; size them independently - Set
opcache.max_accelerated_files = 30000for Drupal; verify withfind . -name '*.php' | wc -l - Set
opcache.validate_timestamps = 0in production; build a deployment step that clears the cache - Always set
opcache.save_comments = 1— Drupal and Symfony need docblock annotations/attributes - Configure
opcache.file_cacheas a fallback for when SHM fills; set directory ownership towww-data - Enable JIT with
opcache.jit = tracingandopcache.jit_buffer_size = 128M - For preloading, run at FPM startup via
opcache.preload— preload stable vendor classes, not generated code - In Docker, set
shm_size: 512mbindocker-compose.yml— the 64 MB default will cause silent eviction - Monitor hit rate and OOM restarts weekly via
opcache_get_status()— tune before problems appear