Deployment And Production

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

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

compose.yml
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.

VariableDefaultWhat it controls
PHP_OPCACHE_VALIDATE_TIMESTAMPS0Whether OPcache checks files for changes. 0 caches files until the container restarts.
PHP_OPCACHE_REVALIDATE_FREQ2Seconds between checks. Used only when PHP_OPCACHE_VALIDATE_TIMESTAMPS=1.
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 cached files.
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 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.

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\n";
}
This file exposes cache and memory statistics. Remove or protect it before you deploy.

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 yes or out of memory restarts keeps rising: raise PHP_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 optimize in 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=1 to check for changes every PHP_OPCACHE_REVALIDATE_FREQ seconds, 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.

compose.yml
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, every php command preloads first, so the script must work outside a web request. Set PHP_OPCACHE_PRELOAD on the running service, not as ENV in your Dockerfile, so build steps like RUN composer install don't depend on it.
  • Running as root needs PHP_OPCACHE_PRELOAD_USER. The images run as www-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:

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

VariableDefaultWhat it controls
PHP_FPM_PM_CONTROLondemand, or dynamic in fpmHow workers start: ondemand (only when requests arrive), dynamic, or static
PHP_FPM_PM_MAX_CHILDREN20Most workers running at once, which is the most requests handled at once
PHP_FPM_PM_START_SERVERS2Workers started with dynamic
PHP_FPM_PM_MIN_SPARE_SERVERS1Fewest idle workers kept with dynamic
PHP_FPM_PM_MAX_SPARE_SERVERS3Most idle workers kept with dynamic
PHP_FPM_PM_MAX_REQUESTS0Requests 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.