Production performance tuning
This guide covers the settings that matter for performance once your application is in production, where our defaults come from, and how to tune them when your app needs more.
PHP OPcache
OPcache "improves PHP performance by storing precompiled script bytecode in shared memory, thereby removing the need for PHP to load and parse scripts on each request" (PHP manual). This means that every request skips the work of reading and parsing your PHP files, so it is faster. It also means that changes to your code are not seen until the cache is cleared, which is why we keep it off by default.
Turning it on
OPcache is controlled by one switch, PHP_OPCACHE_ENABLE.
| Setting | What happens |
|---|---|
PHP_OPCACHE_ENABLE=0 (default) | OPcache is off. Every request reads your files fresh, so edits show up right away when your code is mounted as a volume. |
PHP_OPCACHE_ENABLE=1 | OPcache is on with the tuned defaults below. Files are compiled once and cached until the container restarts. |
We keep OPcache off by default so nobody loses an afternoon wondering why a change is not showing up. Turn it on anywhere you are not editing code in place, such as production or staging:
services:
php:
image: serversideup/php:8.5-fpm-nginx
environment:
PHP_OPCACHE_ENABLE: "1"
php artisan also use OPcache when PHP_OPCACHE_ENABLE_CLI=1, which is the default. A CLI process gets its own cache that ends when the process exits, so the gain there is optimized code rather than caching. Set PHP_OPCACHE_ENABLE_CLI=0 to keep OPcache on for the web server only.Defaults
When PHP_OPCACHE_ENABLE=1, these are the defaults you get. Override any of them with the same variable:
| Variable | Default | What it controls |
|---|---|---|
PHP_OPCACHE_VALIDATE_TIMESTAMPS | 0 | Whether OPcache checks if a file changed. 0 caches files until the container restarts, so PHP_OPCACHE_REVALIDATE_FREQ is never used. |
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 files in the cache. |
PHP_OPCACHE_FORCE_RESTART_TIMEOUT | 180 | Seconds to wait for a scheduled restart before OPcache forces it. |
PHP_OPCACHE_SAVE_COMMENTS | 1 | Keep doc comments in the cache. Disabling it "may break applications and frameworks that rely on comment parsing for annotations, including Doctrine, Zend Framework 2 and PHPUnit." |
PHP_OPCACHE_ENABLE_FILE_OVERRIDE | 0 | Let OPcache answer file_exists() from its cache. Left off because it "risks returning stale data if opcache.validate_timestamps is disabled." |
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 to run the preload script as. Only needed when running as root. |
These are not our numbers. The memory size, file count, interned strings buffer, and validate_timestamps=0 are the values in Symfony's performance guide, which FrankenPHP's performance guide recommends "even if you don't use Symfony." Laravel does not publish OPcache values, but Laravel Forge's OPcache option asks the same thing of you that this configuration does: reload PHP after every deployment. Every variable links to the PHP manual in the environment variable specification.
The most important one is PHP_OPCACHE_VALIDATE_TIMESTAMPS=0. Symfony's guide puts it plainly: "In production servers, PHP files should never change, unless a new application version is deployed." When your code is built into the image, checking for changes 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 that segment. With the defaults, 256 MB is reserved, 32 MB of it holds interned strings, and 224 MB holds compiled code. The JIT buffer is different: when the JIT is on, PHP adds PHP_OPCACHE_JIT_BUFFER_SIZE on top, so "its total size is this value plus opcache.jit_buffer_size" (PHP manual).
Reserving memory is not the same as using it. The container only pays for pages that are written, and every PHP-FPM worker shares the same segment.
Measuring
PHP reports how full the cache is. Put this file in your public directory and open it in a browser or with curl. It has to go through the web server, since the CLI has its own cache:
<?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
";
}
Two of these numbers are available before you serve real traffic. Count the PHP files in your image with find /var/www/html -name '*.php' | wc -l for an upper bound on the file limit, and run your smoke tests or a preload script to fill the cache before you read the memory numbers. Then check:
- Cache full is
yesor out of memory restarts is climbing: raisePHP_OPCACHE_MEMORY_CONSUMPTION. - Cached files is close to the maximum or hash restarts is climbing: 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. When OPcache runs out of room it restarts and clears the whole cache, so requests are slower until it fills back up.
Deploying
With OPcache enabled, files are cached until the container restarts, so every deployment should start a new container. The PHP manual is direct about this: with timestamps off, "you must reset OPcache manually via opcache_reset(), opcache_invalidate() or by restarting the Web server for changes to the filesystem to take effect" (PHP manual).
A few things that catch people out:
- Running cache commands in a live container like
docker exec php artisan optimizewrites new files, but the running web workers keep serving the old cached copies. Restart the container instead. opcache_reset()from the CLI does nothing for the web server. The CLI has its own cache in its own process.- Mounting code as a volume with OPcache enabled means your edits will not show up. Leave OPcache off in development, or set
PHP_OPCACHE_VALIDATE_TIMESTAMPS=1.
PHP_OPCACHE_VALIDATE_TIMESTAMPS=1 restores the Version 4 behavior with a check every PHP_OPCACHE_REVALIDATE_FREQ seconds.Framework notes
Laravel
Our Laravel automations run php artisan optimize in the entrypoint before the web server starts. That includes view:cache, which "precompiles all your Blade views so they are not compiled on demand" (Laravel docs), so the cached config, routes, events, and views are on disk before OPcache sees them.
Queue workers, Horizon, the scheduler, Reverb, and Octane on Swoole or RoadRunner are CLI processes, so they follow PHP_OPCACHE_ENABLE_CLI. Laravel's own advice applies to all of them: after a deploy, "any long-running services such as queue workers, Laravel Reverb, or Laravel Octane should be reloaded / restarted to use the new code" (Laravel docs). A container restart does that.
WordPress
WordPress clears OPcache for the files it writes during core, plugin, and theme updates through the admin, using wp_opcache_invalidate(), so those updates keep working. Files changed outside of WordPress (git pull, WP-CLI, SFTP, or plugins that write PHP files directly) are not 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 the server is shut down" (PHP manual). Requests skip the autoloader for those classes. Symfony generates a preload file for you at config/preload.php. Laravel does not ship one.
Point PHP_OPCACHE_PRELOAD at your script:
services:
php:
image: my-app:latest
environment:
PHP_OPCACHE_ENABLE: "1"
PHP_OPCACHE_PRELOAD: "/var/www/html/config/preload.php"
Things to know before you turn it on:
- The file must exist. PHP refuses to start if the preload script is missing or throws.
- Preloading as root needs
PHP_OPCACHE_PRELOAD_USER. Our images run as an unprivileged user, so this only matters if you run the container as root. The CLI is exempt on PHP 8.3 and newer (PHP manual). - The CLI runs it too. With
PHP_OPCACHE_ENABLE_CLI=1, everyphpcommand preloads before it runs, so the script must work outside of a web request. SetPHP_OPCACHE_ENABLE_CLI=0if you do not want that. SetPHP_OPCACHE_PRELOADon your running service rather than as anENVin your Dockerfile, so build steps likeRUN composer installdo not depend on it. - Preloaded files never change. Even with
PHP_OPCACHE_VALIDATE_TIMESTAMPS=1, preloaded code stays until restart.
JIT
The JIT compiler is off by default, as it is in PHP itself (PHP manual). The PHP JIT RFC found it "doesn't seem to significantly improve real-life apps like WordPress" while the benefit in "non-Web, CPU-intensive scenarios" is "very substantial." Xdebug also turns it off: "When Xdebug is loaded with PHP's JIT on, you will get a warning, and JIT will be disabled" (Xdebug docs).
To enable it, set both variables. The buffer is added on top of PHP_OPCACHE_MEMORY_CONSUMPTION, so 64M with the default makes a 320 MB segment:
services:
php:
image: my-app:latest
environment:
PHP_OPCACHE_ENABLE: "1"
PHP_OPCACHE_JIT: "tracing"
PHP_OPCACHE_JIT_BUFFER_SIZE: "64M"
Measure before and after. If your response times do not move, leave it off.
Advanced settings
If there are options that you don't see in the environment variables, you can set them through a custom ini file. For example, in some ocasions you may want to set:
opcache.huge_code_pages=1copies compiled code into huge pages. It "requires appropriate OS configuration" (PHP manual).opcache.file_cache=/pathadds a second-level cache on disk that helps "at server restart or SHM reset" (PHP manual).
PHP realpath cache
PHP caches the resolved path of every file it opens so it does not repeat the lookup on each request. Symfony's guide pairs this cache with OPcache and says applications that open many PHP files "should use at least" realpath_cache_size=4096K and realpath_cache_ttl=600 (Symfony docs). Our images ship both as PHP_REALPATH_CACHE_SIZE and PHP_REALPATH_CACHE_TTL. PHP disables this cache when open_basedir is set, so leave PHP_OPEN_BASEDIR empty in production unless you need it.