OPcache and Preloading
OPcache keeps compiled bytecode in memory and preloading links classes once at startup; both behave differently in dev and prod.
opcache.preload=/path/to/project/config/preload.phpPHP reads a source file, compiles it to bytecode, runs the bytecode, then throws the bytecode away. OPcache is the extension that keeps the compiled form in shared memory, so the next request that needs Kernel.php does not compile it again. It ships with PHP and is the largest single difference between a slow production host and a fast one.
Preloading
Preloading goes one step further. At startup, before any request arrives, PHP runs one script that loads a list of files, links the classes it finds, and keeps them in memory for the life of the process. Those classes then cost no autoload lookup and no file check at all.
You point PHP at the script with opcache.preload, and opcache.preload_user must name the user PHP runs as. The symfony/framework-bundle recipe writes config/preload.php for you. It requires the *.preload.php files that the compiled production container leaves in var/cache/prod, so the cache has to be warmed before PHP starts, or the script finds nothing and preloading silently does nothing.
Dev and prod differ
In development opcache.validate_timestamps=1, the default, makes PHP re-check each file's modification time, so your edits take effect. In production set it to 0: nothing is re-checked, and a deploy must reload the PHP process. Preloading belongs to production for the same reason - a preloaded class is frozen until restart.
Raise realpath_cache_size as well. Resolving paths through a deep vendor tree is thousands of filesystem calls per request.
Example
<?php
// config/preload.php
//
// Installed by the symfony/framework-bundle recipe. Point php.ini at this
// file with opcache.preload, and set opcache.preload_user to the user that
// PHP-FPM runs as.
//
// The files it loads are written by the container compiler when the prod
// cache is warmed, so this runs after:
// APP_ENV=prod php bin/console cache:warmup
//
// If var/cache/prod is empty, glob() returns an empty list and nothing is
// preloaded - no error, no warning, no speed-up.
foreach (glob(dirname(__DIR__).'/var/cache/prod/*.preload.php') ?: [] as $file) {
require $file;
}When to use it
- Cut boot time on a production host without changing any application code.
- Write a deploy script that leaves OPcache in a correct state rather than a stale one.
- Explain why an edited file has no effect on a production box.
- Reduce the filesystem calls caused by a deep vendor directory.
More examples
The php.ini block for a production host
These are the settings that matter for a Symfony application specifically: a large interned strings buffer because of long class names, timestamp validation off, and a generous realpath cache. Keep the file separate from the distribution php.ini so a PHP upgrade does not overwrite it, and do not copy validate_timestamps=0 into a development image.
; /etc/php/8.5/fpm/conf.d/99-symfony.ini
; Compiled bytecode. 256 MB is comfortable for a Symfony application.
opcache.memory_consumption=256
; How many files may be cached at once.
opcache.max_accelerated_files=32531
; Fully-qualified class names are long and repeated everywhere, so the
; 8 MB default for interned strings is too small here.
opcache.interned_strings_buffer=32
; Production only: stop re-checking file modification times.
opcache.validate_timestamps=0
; Preloading. preload_user is required and must be the PHP-FPM user.
opcache.preload=/var/www/app/current/config/preload.php
opcache.preload_user=www-data
; Path resolution through vendor/ is thousands of stat() calls per request.
realpath_cache_size=4096K
realpath_cache_ttl=600A deploy that leaves OPcache correct
The order is the point: install, then build the classmap, then warm the prod cache so the preload files exist, then reload PHP last. An authoritative classmap means the autoloader never scans the filesystem for a class, which pairs well with preloading because both assume the code does not change under them.
# deploy.sh - the new release directory is already in place
set -eu
composer install --no-dev --no-scripts
# -a is the short form of --classmap-authoritative, and it implies
# --optimize (-o). These two lines are the same command:
# composer dump-autoload --classmap-authoritative --no-dev
# composer dump-autoload -a --no-dev
composer dump-autoload -a --no-dev
APP_ENV=prod php bin/console cache:clear
APP_ENV=prod php bin/console cache:warmup # writes var/cache/prod/*.preload.php
# OPcache and the preloaded classes live inside the PHP process, and the CLI
# has its own copy. With validate_timestamps=0 or preloading on, only this
# reload makes the new release visible.
sudo systemctl reload php8.5-fpmDumping the container into one file
A production container can be hundreds of generated files, and every one of them is an include. Inlining the factories trades a bigger single file for far fewer filesystem operations, which is exactly what preloading and an authoritative classmap are also optimising for. Measure it; the gain depends on how many services your application has.
# config/services.yaml
parameters:
# Inline service factories so the compiled container is one large file
# instead of many small ones: fewer includes and fewer stat() calls per
# request. The leading dot marks a parameter that is only used while the
# container is being compiled.
.container.dumper.inline_factories: true
Discussion