Advanced Guides

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.

SettingWhat 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=1OPcache 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:

compose.yml
services:
  php:
    image: serversideup/php:8.5-fpm-nginx
    environment:
      PHP_OPCACHE_ENABLE: "1"
CLI commands like 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:

VariableDefaultWhat it controls
PHP_OPCACHE_VALIDATE_TIMESTAMPS0Whether OPcache checks if a file changed. 0 caches files until the container restarts, so PHP_OPCACHE_REVALIDATE_FREQ is never used.
PHP_OPCACHE_MEMORY_CONSUMPTION256Size of the shared memory segment in megabytes.
PHP_OPCACHE_INTERNED_STRINGS_BUFFER32Megabytes inside that segment for interned strings.
PHP_OPCACHE_MAX_ACCELERATED_FILES32531Maximum number of files in the cache.
PHP_OPCACHE_FORCE_RESTART_TIMEOUT180Seconds to wait for a scheduled restart before OPcache forces it.
PHP_OPCACHE_SAVE_COMMENTS1Keep 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_OVERRIDE0Let OPcache answer file_exists() from its cache. Left off because it "risks returning stale data if opcache.validate_timestamps is disabled."
PHP_OPCACHE_JIToffThe JIT compiler. See JIT.
PHP_OPCACHE_JIT_BUFFER_SIZE0Memory 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:

public/opcache-status.php
<?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
";
}
This file exposes cache and memory statistics. Remove it before deploying, or protect it.

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 yes or out of memory restarts is climbing: raise PHP_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 optimize writes 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.
If you need change detection with OPcache enabled, 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:

compose.yml
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, every php command preloads before it runs, so the script must work outside of a web request. Set PHP_OPCACHE_ENABLE_CLI=0 if you do not want that. Set PHP_OPCACHE_PRELOAD on your running service rather than as an ENV in your Dockerfile, so build steps like RUN composer install do 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:

compose.yml
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=1 copies compiled code into huge pages. It "requires appropriate OS configuration" (PHP manual).
  • opcache.file_cache=/path adds 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.

The full list of variables and their defaults is in the environment variable specification.