PHP performance tuning on Ubuntu: OPcache and PHP-FPM

PHP performance tuning on a server mostly comes down to two things: OPcache, which keeps compiled scripts in memory, and the PHP-FPM process manager, which decides how many requests run at once. Both are on in Ubuntu’s packages, but with defaults made for a small machine. This guide is for PHP 8.3 on Ubuntu 24.04 (22.04 has PHP 8.1: write 8.1 instead of 8.3 in paths and service names). The numbers are starting points to check against your own load. To see your PHP version, run: ls /etc/php/

OPcache settings for production

OPcache is on by default and gets 128 MB of memory and room for 10,000 scripts. A framework with its dependencies easily has more files. Count the PHP files of your site and set opcache.max_accelerated_files above that number:

find /var/www/example.com -type f -name '*.php' | wc -l

Ubuntu reads every .ini in /etc/php/8.3/fpm/conf.d/ after php.ini, so your own file there survives package updates. Create it:

sudo nano /etc/php/8.3/fpm/conf.d/99-tuning.ini
/etc/php/8.3/fpm/conf.d/99-tuning.ini
; OPcache opcache.enable=1 opcache.memory_consumption=256 opcache.interned_strings_buffer=16 opcache.max_accelerated_files=20000 opcache.validate_timestamps=0

opcache.validate_timestamps=0 means PHP no longer checks whether files have changed, which saves a disk check on every request. The price: after each deploy you must restart PHP-FPM, otherwise the old code keeps running. If you edit files on the server by hand or your deploy can’t restart services, keep opcache.validate_timestamps=1 and set opcache.revalidate_freq=60 instead: PHP then checks files once a minute.

sudo php-fpm8.3 -t sudo systemctl restart php8.3-fpm

The check must say configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful. To see how full the cache is, make a temporary page that prints the OPcache status, open it in the browser and delete it right away, because it shows the paths of your scripts to anyone:

echo '<?php print_r(opcache_get_status(false));' | sudo tee /var/www/example.com/opcache-check.php

Open https://example.com/opcache-check.php, read what you need, then delete the file:

sudo rm /var/www/example.com/opcache-check.php

On the page, [cache_full] => 1 or little [free_memory] left means opcache.memory_consumption is too small. [num_cached_scripts] near opcache.max_accelerated_files means that one is. [opcache_hit_rate] should be close to 100 after the site has worked for a while.

JIT in PHP 8.3

The JIT compiles hot code to machine code. In PHP 8.3 it is off because opcache.jit_buffer_size is 0 by default. It helps CPU-heavy code, such as calculations or image processing. A typical site that mostly waits for the database gains little from it, so measure before and after. To turn it on, add two lines to the same 99-tuning.ini and restart PHP-FPM:

/etc/php/8.3/fpm/conf.d/99-tuning.ini
; JIT opcache.jit=tracing opcache.jit_buffer_size=64M

In the OPcache status, [jit] then shows [enabled] => 1. The JIT memory comes on top of opcache.memory_consumption. With Xdebug loaded, PHP turns the JIT off.

PHP-FPM: pm modes and how to compute pm.max_children

Each PHP-FPM process serves one request at a time, so pm.max_children is how many PHP requests run at once. Ubuntu sets it to 5, which is too few for most sites. Too many is worse, though: if the processes don’t fit in memory, the server starts swapping or kills processes. Compute it from memory. First see how much memory is free and how much one PHP process takes on average under real load:

free -m ps --no-headers -o rss -C php-fpm8.3 | awk '{ sum += $1; n++ } END { print sum / n / 1024 " MB" }'

Then: pm.max_children = memory you can give to PHP / average process size. Leave memory for the system, the database and everything else on the server. For example, a 4 GB server with MySQL that takes 1.5 GB: leave 0.5 GB for the system, give PHP 2 GB, with processes of 60 MB that is 2048 / 60 ≈ 34. Round down to 30. The figure from ps counts the shared OPcache memory in every process, so the real headroom is a bit larger.

  • pm = dynamic (Ubuntu’s default): keeps some idle processes ready and starts more up to pm.max_children. Fits most servers.
  • pm = static: always runs exactly pm.max_children processes. For a busy server that runs only this site and has memory for all of them.
  • pm = ondemand: starts processes only on requests and stops them after pm.process_idle_timeout (10 seconds by default). For a small server or many pools with little traffic.
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
/etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic pm.max_children = 30 pm.start_servers = 6 pm.min_spare_servers = 4 pm.max_spare_servers = 8 pm.max_requests = 500

pm.start_servers is usually the middle between the two spare values. pm.max_requests = 500 restarts each process after 500 requests, which keeps memory leaks in libraries from growing forever. The default 0 never restarts them.

sudo php-fpm8.3 -t sudo systemctl restart php8.3-fpm sudo grep max_children /var/log/php8.3-fpm.log

A line WARNING: [pool www] server reached pm.max_children setting (30), consider raising it means requests had to wait for a free process. Raise the limit only if memory allows, otherwise look for slow requests first.

How to find slow PHP requests with slowlog

PHP-FPM can write a backtrace of every request that runs longer than a given time: you see the script, the function and the line where it was stuck. It is off by default. Add two lines to the same pool file:

/etc/php/8.3/fpm/pool.d/www.conf
request_slowlog_timeout = 5s slowlog = /var/log/php8.3-fpm.slow.log
sudo php-fpm8.3 -t sudo systemctl restart php8.3-fpm sudo tail -n 50 /var/log/php8.3-fpm.slow.log

Each entry starts with the time, the pool and script_filename, followed by the call stack, the deepest call first. Start with 5 seconds and lower it once the worst requests are fixed.

Realpath cache

PHP keeps the resolved paths of included files in the realpath cache so it doesn’t ask the disk each time. In current PHP versions it gets 4 MB, which is enough for most projects, and keeps entries for 120 seconds. With many files and opcache.validate_timestamps=0, a longer time saves a few more disk checks:

/etc/php/8.3/fpm/conf.d/99-tuning.ini
; Realpath cache realpath_cache_size=4096K realpath_cache_ttl=600

Restart PHP-FPM after the change. The cache doesn’t work at all when open_basedir is set. To see how full it is, put <?php echo realpath_cache_size(); in a temporary page the same way as the OPcache check: it prints the bytes in use. Delete the page afterwards.

If you also work on a Mac

MacWashing, System Junk: Xcode junk, user caches, installers and logs found, 22.83 GB ready to be cleaned

System Junk in MacWashing finds the caches of every app it knows, including those hidden inside app data, plus logs, Xcode junk and leftovers of deleted apps. It shows how much each takes and clears them in one go. Anything debatable stays unchecked until you decide.

Free trial · macOS 14 or later

Questions and answers

What gives the most in PHP performance tuning?

An OPcache big enough for all your scripts and a pm.max_children that fits in memory. After that, look for slow requests with slowlog: most time is usually lost in database queries, not in PHP itself.

My changes don’t show up on the site. Why?

With opcache.validate_timestamps=0, PHP keeps running the cached code until PHP-FPM restarts. Restart it after each deploy, or add the command to your deploy script: sudo systemctl restart php8.3-fpm

How many pm.max_children for a 2 GB server?

If PHP can have about 1.2 GB and a process takes 50 MB, about 24. Measure the process size on your own site: a heavy framework can take 100 MB or more per process.