Production performance tuning
This page covers the settings that change PHP performance in production: OPcache, the realpath cache, and PHP-FPM workers. It explains where the defaults come from and how to measure your app before you change them.
PHP OPcache
OPcache stores compiled PHP bytecode in shared memory. Requests skip reading and parsing your PHP files (PHP manual). It also means code changes don't show up until the cache is cleared, which is why it is off by default.
Turn it on in production
| Setting | What happens |
|---|---|
PHP_OPCACHE_ENABLE=0 (default) | OPcache is off. Every request reads your files, so edits to mounted code show up right away. |
PHP_OPCACHE_ENABLE=1 | OPcache is on with the defaults below. Files are compiled once and cached until the container restarts. |
Turn it on anywhere you aren't editing code in place, such as production and staging:
services:
php:
image: serversideup/php:8.5-fpm-nginx
environment:
PHP_OPCACHE_ENABLE: "1"
PHP_OPCACHE_ENABLE_CLI=1 is the default, so CLI commands like php artisan also use OPcache. Each CLI process gets its own cache that ends when the process exits. Set PHP_OPCACHE_ENABLE_CLI=0 to use OPcache for the web server only.Defaults
These apply once PHP_OPCACHE_ENABLE=1. Override any of them with the same variable. The environment variable specification lists the rest.
| Variable | Default | What it controls |
|---|---|---|
PHP_OPCACHE_VALIDATE_TIMESTAMPS | 0 | Whether OPcache checks files for changes. 0 caches files until the container restarts. |
PHP_OPCACHE_REVALIDATE_FREQ | 2 | Seconds between checks. Used only when PHP_OPCACHE_VALIDATE_TIMESTAMPS=1. |
PHP_OPCACHE_MEMORY_CONSUMPTION | 256 | Size of the shared memory segment, in megabytes. |
PHP_OPCACHE_INTERNED_STRINGS_BUFFER | 32 | Megabytes inside that segment for interned strings. |
PHP_OPCACHE_MAX_ACCELERATED_FILES | 32531 | Maximum number of cached files. |
PHP_OPCACHE_JIT | off | The JIT compiler. See JIT. |
PHP_OPCACHE_JIT_BUFFER_SIZE | 0 | Memory for JIT code. |
PHP_OPCACHE_PRELOAD | "" | Path to a preload script. See Preloading. |
PHP_OPCACHE_PRELOAD_USER | "" | User that runs the preload script. Needed only when running as root. |
The memory size, file count, interned strings buffer, and validate_timestamps=0 come from Symfony's performance guide, which FrankenPHP's performance guide recommends for any framework. Laravel Forge's OPcache option makes the same tradeoff: reload PHP after every deployment.
PHP_OPCACHE_VALIDATE_TIMESTAMPS=0 matters most. When your code is built into the image, files never change between deployments. Checking them is wasted work.
Memory
OPcache reserves one shared memory segment of PHP_OPCACHE_MEMORY_CONSUMPTION megabytes when PHP starts. The interned strings buffer lives inside it. With the defaults, 32 MB of the 256 MB holds interned strings and 224 MB holds compiled code. The JIT buffer is added on top of the segment (PHP manual).
Reserved memory isn't used memory. The container only pays for pages that are written. Every PHP-FPM worker shares the same segment.
Measure the cache
Put this file in your public directory and request it through the web server. The CLI has its own cache. Running the file with php tells you nothing about the web server.
<?php
header('Content-Type: text/plain');
$status = opcache_get_status(false);
$stats = $status['opcache_statistics'];
$memory = $status['memory_usage'];
$strings = $status['interned_strings_usage'];
$toMegabytes = fn (int $bytes) => round($bytes / 1048576) . ' MB';
$files = "{$stats['num_cached_keys']} of {$stats['max_cached_keys']}";
$report = [
'Cache full' => $status['cache_full'] ? 'yes' : 'no',
'Cached files' => $files,
'Memory used' => $toMegabytes($memory['used_memory']),
'Memory free' => $toMegabytes($memory['free_memory']),
'Interned strings free' => $toMegabytes($strings['free_memory']),
'Out of memory restarts' => $stats['oom_restarts'],
'Hash restarts' => $stats['hash_restarts'],
];
foreach ($report as $label => $value) {
echo "$label: $value\n";
}
For an upper bound on the file count, count the PHP files in your image with find /var/www/html -name '*.php' | wc -l. Fill the cache with your smoke tests or a preload script before you read the memory numbers. Then:
- Cache full is
yesor out of memory restarts keeps rising: raisePHP_OPCACHE_MEMORY_CONSUMPTION. - Cached files is close to the maximum or hash restarts keeps rising: raise
PHP_OPCACHE_MAX_ACCELERATED_FILES. - Interned strings free is near zero: raise
PHP_OPCACHE_INTERNED_STRINGS_BUFFER.
Restarts are what you want to avoid. Each one clears the whole cache, and requests are slower until it fills again.
Start a new container for every deployment
With timestamps off, PHP only sees new files after opcache_reset(), opcache_invalidate(), or a restart (PHP manual). Deploy a new container instead of changing files in a running one.
- Running
php artisan optimizein a live container writes new cache files, but the web workers keep serving the old compiled copies. Restart the container instead. opcache_reset()from the CLI doesn't clear the web server's cache. The CLI has its own cache in its own process.- Mounting code with OPcache on means your edits won't show up. Leave OPcache off in development, or set
PHP_OPCACHE_VALIDATE_TIMESTAMPS=1to check for changes everyPHP_OPCACHE_REVALIDATE_FREQseconds, as Version 4 did.
Laravel
With AUTORUN_ENABLED=true, the container runs php artisan optimize before the web server starts. That caches config, routes, events, and views, including the compiled Blade views (Laravel docs), so they are on disk before OPcache reads them. Automations are off by default; without them, run php artisan optimize yourself when the container starts.
Queue workers, Horizon, the scheduler, Reverb, and Octane on Swoole or RoadRunner are CLI processes, so PHP_OPCACHE_ENABLE_CLI applies to them. Laravel says to restart long-running processes after a deploy (Laravel docs). A new container does that.
WordPress
WordPress clears OPcache for the files it writes during core, plugin, and theme updates from the admin, using wp_opcache_invalidate(). Files changed any other way (git pull, WP-CLI, SFTP, or plugins that write PHP files) aren't picked up until the container restarts. If you use the volume-based approach, restart after those changes or set PHP_OPCACHE_VALIDATE_TIMESTAMPS=1.
Preloading
Preloading compiles a set of files into OPcache when PHP starts and keeps them there until PHP stops (PHP manual). Requests skip the autoloader for those classes. Symfony generates a preload file at config/preload.php. Laravel doesn't ship one.
services:
php:
image: my-app:1.0.0
environment:
PHP_OPCACHE_ENABLE: "1"
PHP_OPCACHE_PRELOAD: "/var/www/html/config/preload.php"
- The file must exist. PHP won't start if the preload script is missing or throws.
- The CLI runs it too. With
PHP_OPCACHE_ENABLE_CLI=1, everyphpcommand preloads first, so the script must work outside a web request. SetPHP_OPCACHE_PRELOADon the running service, not asENVin your Dockerfile, so build steps likeRUN composer installdon't depend on it. - Running as root needs
PHP_OPCACHE_PRELOAD_USER. The images run aswww-data, so this only applies if you change the user. The CLI is exempt on PHP 8.3 and newer (PHP manual). - Preloaded files never change. They stay until restart, even with
PHP_OPCACHE_VALIDATE_TIMESTAMPS=1.
JIT
The JIT compiler is off by default, as it is in PHP. The PHP JIT RFC found little gain for web apps like WordPress and large gains for CPU-heavy, non-web work. Xdebug disables the JIT when it is loaded (Xdebug docs).
To enable it, set both variables. The buffer is added on top of PHP_OPCACHE_MEMORY_CONSUMPTION. With the defaults, 64M makes a 320 MB segment:
services:
php:
image: my-app:1.0.0
environment:
PHP_OPCACHE_ENABLE: "1"
PHP_OPCACHE_JIT: "tracing"
PHP_OPCACHE_JIT_BUFFER_SIZE: "64M"
Measure response times before and after. If they don't change, leave it off.
Settings without a variable
Set any other OPcache option in a custom ini file. Two you may want:
opcache.huge_code_pages=1copies compiled code into huge pages. It needs OS support (PHP manual).opcache.file_cache=/pathadds a cache on disk that survives a PHP restart (PHP manual).
PHP realpath cache
PHP caches the resolved path of every file it opens. Symfony recommends at least realpath_cache_size=4096K and realpath_cache_ttl=600 for apps that open many files. Those are the defaults for PHP_REALPATH_CACHE_SIZE and PHP_REALPATH_CACHE_TTL. PHP disables this cache when open_basedir is set. Leave PHP_OPEN_BASEDIR empty unless you need it.
PHP-FPM workers
The fpm, fpm-nginx, and fpm-apache images run PHP-FPM, which handles each request in a worker process. The defaults suit a small container:
| Variable | Default | What it controls |
|---|---|---|
PHP_FPM_PM_CONTROL | ondemand, or dynamic in fpm | How workers start: ondemand (only when requests arrive), dynamic, or static |
PHP_FPM_PM_MAX_CHILDREN | 20 | Most workers running at once, which is the most requests handled at once |
PHP_FPM_PM_START_SERVERS | 2 | Workers started with dynamic |
PHP_FPM_PM_MIN_SPARE_SERVERS | 1 | Fewest idle workers kept with dynamic |
PHP_FPM_PM_MAX_SPARE_SERVERS | 3 | Most idle workers kept with dynamic |
PHP_FPM_PM_MAX_REQUESTS | 0 | Requests before a worker restarts. 0 never restarts it. |
Set PHP_FPM_PM_MAX_CHILDREN from memory: divide the memory you can give PHP by the memory one worker uses under load. Each worker can grow to PHP_MEMORY_LIMIT (256M by default). Twenty workers can use far more memory than a small server has. Watch per-process memory with docker stats or ps while you run a load test, then size the pool so it fits.
If a request queue builds up while CPU is idle, raise PHP_FPM_PM_MAX_CHILDREN. If the container runs out of memory, lower it. Set PHP_FPM_PM_MAX_REQUESTS to a value such as 500 if worker memory grows over time from a leak.