YOUR DIGITAL HUB
← Back to blog

OPcache in production: configuration, preloading, JIT and pitfalls

· 9 min read
Cover — OPcache in production

Without OPcache, no optimization matters

OPcache is the single most profitable performance measure you can apply to a PHP application. Free, bundled with PHP since 5.5, enabled by default, measured impact of 3x to 5x on Symfony. And yet, in a third of performance audits we run, OPcache is misconfigured, or even disabled.

This article details what OPcache does, the configuration we systematically deploy in production, preloading and JIT usage, monitoring, deploy-time purge and pitfalls that silently kill performance.

What OPcache does

PHP is an interpreted language with runtime compilation. Without OPcache, every request:

  1. Reads PHP files from disk.
  2. Parses text into an AST (Abstract Syntax Tree).
  3. Compiles the AST into opcodes (Zend VM bytecode).
  4. Executes the opcodes.

Steps 1 to 3 are redundant: files do not change between requests (outside development). OPcache caches the compiled bytecode in shared memory (SHM). Subsequent requests short-circuit directly to step 4.

Measured impact on a typical Symfony application loading 300 PHP files per request:

Scenario Per-request time Factor
Without OPcache 180 ms 1x
OPcache enabled, default config 45 ms 4x
OPcache + tuned config + preload 32 ms 5.6x
OPcache + preload + JIT tracing 28 ms 6.4x

Numbers vary by application but the order of magnitude is stable. No application-level optimization rivals this infrastructure switch.

Recommended production configuration

Dedicated file /etc/php/8.3/fpm/conf.d/10-opcache.ini that we deploy nearly as-is.

; ========================================
; OPcache - YDH production configuration
; ========================================

; Enabled
opcache.enable = 1
opcache.enable_cli = 0

; SHM size for bytecode (MB)
; Rule: 128 MB per 1000 loaded classes
opcache.memory_consumption = 256

; Interned strings buffer (class names, constants)
; Rule: 16 to 32 MB for Symfony
opcache.interned_strings_buffer = 32

; Max cacheable files
; Rule: 2x the actual file count
opcache.max_accelerated_files = 20000

; CRITICAL in prod: do not revalidate timestamps
; Measured saving: 10 to 30% CPU
opcache.validate_timestamps = 0

; If validate_timestamps=1, frequency in seconds (dev only)
; opcache.revalidate_freq = 2

; Keep comments: MANDATORY for Symfony/Doctrine
opcache.save_comments = 1

; Advanced optimizations
opcache.enable_file_override = 0
opcache.huge_code_pages = 1
opcache.fast_shutdown = 1

; Error logging
opcache.log_verbosity_level = 1

; JIT (PHP 8.0+)
opcache.jit = tracing
opcache.jit_buffer_size = 64M

; Preloading (PHP 7.4+)
opcache.preload = /var/www/html/config/preload.php
opcache.preload_user = www-data

Each parameter deserves a word.

memory_consumption

The SHM cache size. Too small, the cache fills up and starts evicting bytecode: random cache misses appear. Too large, you waste RAM. 256 MB covers 90% of Symfony applications. For projects with many dependencies (5000+ classes), bump to 384 or 512 MB.

validate_timestamps

The most-misconfigured parameter in production. With 1, PHP stats every file on every request to check for disk changes. On 300 files and 1000 requests/second, that is 300,000 useless stat() syscalls per second.

In production, code only changes at deploy time. Disable revalidation (validate_timestamps = 0) and explicitly purge on deploy.

save_comments

Classic oversight that silently breaks Symfony. PHP 8 attributes (#[Route], #[ORM\Entity]) are read by reflection from the bytecode. With save_comments = 0, they vanish and Symfony no longer finds routes, Doctrine no longer finds mappings. Always 1.

interned_strings_buffer

Class, constant and function names are stored once in shared memory. 32 MB is enough for Symfony. If you see OPcache interned strings buffer exhausted messages, bump to 48 or 64.

Preloading (PHP 7.4+)

Preloading loads PHP files at FPM startup, before any request, and makes them available to all workers without extra compilation. Bytecode plus class structures live in shared memory.

Measured extra gain on Symfony: 5 to 15% on top of OPcache alone. Mostly visible on cold start (first request after FPM restart).

Symfony preload script

<?php
// config/preload.php
declare(strict_types=1);

require dirname(__DIR__) . '/vendor/autoload_runtime.php';

if (file_exists(dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php')) {
    require dirname(__DIR__) . '/var/cache/prod/App_KernelProdContainer.preload.php';
}

Symfony automatically generates a .preload.php file listing classes to preload during cache:warmup. Just point opcache.preload at it.

Preloading pitfalls

  • Preloaded classes cannot be reloaded without a restart. If you modify a preloaded class, you need systemctl restart php8.3-fpm. Hence the importance of including preload only in production, not dev.
  • Classes with runtime external dependencies fail at preload. Example: a class instantiating an HTTP client in its constructor. Avoid, or exclude from preload.
  • preload_user must match the FPM user. On Debian/Ubuntu: www-data. Otherwise permission denied.
  • Incompatibilities with some extensions. Before PHP 8.1, preload did not work with Xdebug. Check.

JIT (PHP 8.0+)

JIT compiles bytecode into native machine code (x86_64, ARM64) at runtime. Two main modes.

Tracing JIT (opcache.jit=tracing)

The most useful: compiles hot paths detected dynamically. Typical gain on a PHP web app: 5 to 15% on I/O-bound workloads (most web apps). Little impact on workloads already CPU-bound (scientific computing, crypto) where JIT can yield 50 to 100%.

Function JIT (opcache.jit=function)

Compiles all functions on load. Simpler but less aggressive.

When JIT helps, when it does not

Workload type Typical JIT gain
Classic Symfony web app (I/O-bound) +5 to +15%
REST CRUD API (I/O-bound) +3 to +10%
PDF generation (CPU-bound) +30 to +80%
Heavy XML or JSON parsing +20 to +40%
Pure PHP matrix math +100 to +300%
Long async workers (Messenger) +10 to +25%

Rule: enable JIT by default in tracing mode, it has never been harmful in our benchmarks. Real gain depends on the workload and must be measured (Blackfire before/after). Memory budget: opcache.jit_buffer_size = 64M covers most cases.

JIT bugs in production

Historically, JIT had bugs (segfault, wrong values) up to PHP 8.1.x. Since PHP 8.2 it has been stable in production. We now enable it without hesitation, but with APM monitoring that alerts on 5xx.

Monitoring OPcache

Without monitoring, you do not know whether OPcache actually works.

Via opcache_get_status()

The PHP function returns a full array. Expose via an admin endpoint or a Prometheus sidecar.

<?php
// public/_admin/opcache-status.php
header('Content-Type: application/json');
$status = opcache_get_status(false);
echo json_encode([
    'enabled' => $status['opcache_enabled'],
    'memory_used_mb' => round($status['memory_usage']['used_memory'] / 1024 / 1024, 2),
    'memory_free_mb' => round($status['memory_usage']['free_memory'] / 1024 / 1024, 2),
    'memory_used_pct' => round(
        $status['memory_usage']['used_memory']
        / ($status['memory_usage']['used_memory'] + $status['memory_usage']['free_memory']) * 100,
        1
    ),
    'cached_scripts' => $status['opcache_statistics']['num_cached_scripts'],
    'max_cached_files' => $status['opcache_statistics']['max_cached_keys'],
    'hits' => $status['opcache_statistics']['hits'],
    'misses' => $status['opcache_statistics']['misses'],
    'hit_rate' => round($status['opcache_statistics']['opcache_hit_rate'], 2),
    'oom_restarts' => $status['opcache_statistics']['oom_restarts'],
    'hash_restarts' => $status['opcache_statistics']['hash_restarts'],
    'manual_restarts' => $status['opcache_statistics']['manual_restarts'],
]);

Protect the endpoint with admin authentication. Wire Prometheus or Datadog to scrape and alert.

Metrics to watch

Metric Alert threshold
memory_used_pct > 90% (eviction risk)
hit_rate < 99%
oom_restarts > 0 over 1 hour
hash_restarts > 0 over 1 hour
cached_scripts Flatlined: no climb after deploy

oom_restarts (out of memory) signal that memory_consumption is undersized. hash_restarts that max_accelerated_files is too low.

Tools

  • OPcache GUI (amnuts/opcache-gui): simple web interface, protect it.
  • Tideways: OPcache monitoring built into the dashboard.
  • Datadog PHP integration: collects OPcache metrics via the agent.
  • Prometheus via opcache-exporter: standard-format export.

Deploy-time purge

With validate_timestamps = 0, OPcache never sees file changes. Purge it explicitly at deploy time. Three methods.

1. FPM restart

Safest, most brutal. Purges everything, including the preload.

sudo systemctl reload php8.3-fpm   # reload preserves connections

In practice reload on FPM reloads config and workers. Graceful, no downtime.

2. HTTP purge endpoint

An endpoint that calls opcache_reset(). Triggered by the CI/CD pipeline after deploy.

// public/_admin/opcache-reset.php
&#x3C;?php
$token = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '';
if (!hash_equals(getenv('ADMIN_RESET_TOKEN'), $token)) {
    http_response_code(403);
    exit;
}
if (opcache_reset()) {
    echo 'OPcache reset';
} else {
    http_response_code(500);
    echo 'Reset failed';
}

Call from the deploy runner:

curl -X POST "https://app.example.com/_admin/opcache-reset.php" \
  -H "X-Admin-Token: ${ADMIN_RESET_TOKEN}"

Warning: this endpoint must be rigorously protected. An arbitrary opcache_reset() is a trivial DOS vector (multiplies cold starts).

3. Cachetool

gordalina/cachetool exposes a CLI that talks to FPM via its socket. No HTTP endpoint to expose.

# From the deploy runner
cachetool opcache:reset --fcgi=/var/run/php/php8.3-fpm.sock

Cleanest approach when you have shell access on the server.

Container rebuild vs live reload

With Docker/Kubernetes, the question plays out differently. Two approaches.

Immutable infrastructure (recommended)

Every deploy builds a new Docker image with the code. The rollout replaces containers one by one (rolling update). Each new container starts with an empty cache, fills up on the first request. Never any purge to manage.

FROM php:8.3-fpm-alpine

# Code + preload script
COPY . /var/www/html
WORKDIR /var/www/html

# OPcache config
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/

Live reload (legacy)

On traditional VPS, you push code, purge OPcache, do not restart FPM. Less clean, faster. Still common on many hosted infrastructures.

Frequent audit pitfalls

Recurring mistakes with their impact.

  • validate_timestamps = 1 in prod. 10 to 30% CPU wasted. Check with php -i | grep validate_timestamps.
  • save_comments = 0. Symfony annotations break, routes not found. Shows up on first cache warmup.
  • max_accelerated_files too low. When OPcache hits the cap, it stops caching new files. cached_scripts plateaus at 10000 when it should reach 15000.
  • SHM silently saturated. memory_used_pct at 100%, hit rate collapses, performance too. Alerting is mandatory.
  • No deploy purge. Code changes but OPcache keeps serving old bytecode. Impossible-to-diagnose bugs.
  • Preload on classes with side effects. Preload runs constructors at startup: network calls, disk writes, failure.
  • OPcache disabled for CLI (good) but forgotten for long workers (Messenger). Async workers deserve OPcache enabled too, with opcache.enable_cli = 1 scoped to them.

Conclusion

OPcache is the optimization to do first, before any other. Fifteen well-chosen configuration lines, automated purge at deploy, basic monitoring of hits and memory: three times faster, without changing a single line of application code.

For a PHP configuration audit, a full server tuning before a traffic peak or observability chain setup, reach out at contact@your-digital-hub.com or explore our performance and scalability service. See also our article PHP caching strategies for the next step.