PHP performance

Fast PHP is tuned, not bought.

Our team has worked in PHP for twenty-five years and knows where a request loses its time. We find it on your server and take it back, one layer at a time.

Anatomy of one request

A page is a chain of waits: finding the server, opening the connection, queueing for PHP, compiling, running, asking the database, sending. Switch between the states to see which links we shorten.

Illustrative: proportions, not a measurement
  1. DNS lookup Answered by the visitor's resolver. The same in every state: this part is the visitor's network. The same in every state: this part is the visitor's network.
  2. Connection and TLS An older TLS setup, a new connection for every file. TLS 1.3 and one HTTP/2 connection reused for every file. TLS 1.3 and one HTTP/2 connection reused for every file.
  3. Waiting for a PHP worker Every worker is busy, so the request waits in the queue. A pool sized from memory: a free worker is ready. No PHP worker is needed.
  4. Compiling the scripts PHP reads and compiles every file on every request. OPcache serves the compiled scripts from memory. Nothing to compile.
  5. Finding files and classes The autoloader searches directories for each class. A classmap autoloader and a warm realpath cache. Nothing to load.
  6. Running your code The application's own work. Much the same: JIT rarely changes this for a typical site. Your code does not run for this visit.
  7. Database queries Missing indexes, and the same query on every page. Indexes added, repeated answers kept in Redis. No query is sent.
  8. Sending the page Sent uncompressed. Compressed before it leaves the server. The finished page, straight from the page cache.
The network Work on our server First byte reaches the visitor

The bars above are a drawing. This figure is a measurement.

< 20 ms The first byte of a page, from our server, measured on the server itself with encryption included.

The rest of the time is the distance between our servers in Germany and you. That part depends on where you are, so measure it from your own connection.

Measure it from your connection

The settings that remove most of the waiting

Compiling, finding files and loading classes happen before your code runs a single line. These settings make them happen once instead of on every request.

conf.d/10-opcache.iniAn example - the values are set per site
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/srv/app/preload.php
opcache.preload_user=www-data
opcache.jit=tracing
opcache.jit_buffer_size=64M
realpath_cache_size=4096K
realpath_cache_ttl=600
deploy
$ composer install --no-dev --classmap-authoritative
  1. OPcache

    PHP compiles every file before it runs it. OPcache keeps the compiled result in shared memory, so the next request skips that work. We size its memory and file count to the site and read its hit rate to confirm nothing is being thrown out.

    In production PHP stops checking every file for changes; each deploy clears the cache itself.

  2. Preloading

    For a framework, preloading loads its core files once when PHP starts and keeps them ready for every request. A change to those files needs a restart, so the restart is part of the deploy.

  3. JIT

    The JIT turns hot PHP code into machine code. It helps work that keeps the processor busy - image processing, calculations, long loops. A typical site spends its time waiting for the database and the network, and there the JIT changes little. We turn it on where a measurement shows a difference, not everywhere.

  4. Realpath cache

    Every include asks the disk where a file really is. A larger realpath cache keeps those answers in memory, which counts for frameworks that load hundreds of files per request.

  5. Composer autoloader

    An authoritative classmap finds every class in one lookup instead of searching directories, and the development packages stay off the server.

The tuning dial: PHP-FPM

PHP-FPM keeps a pool of PHP workers. Too few and requests queue; too many and the server runs out of memory and slows down for everyone. The pool is set from what the server actually has.

pm = static
pm.max_children = 64

A fixed number of workers, always running

For a server given to one busy site. No time is spent starting processes, and the memory it uses is known in advance.

pm = dynamic
pm.max_children = 64
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12

Some workers ready, more under load

Keeps a few idle workers waiting and adds more as traffic rises, up to the limit. The usual choice for a site with daily peaks.

pm = ondemand
pm.max_children = 64
pm.process_idle_timeout = 10s

Workers only when a request arrives

For many small sites on one server: memory goes to the sites being visited, and the first request after a quiet spell pays a short start-up.

How we set pm.max_children

pm.max_children = memory for PHP ÷ memory per worker

pm.max_children 64
$ ps --no-headers -o rss -C php-fpm8.3 | awk '{s+=$1} END {print s/NR/1024 " MB"}'

A worked example, not a recommendation: the real numbers come from your server, and we leave headroom for the peak.

pm.max_requests
Recycles a worker after a set number of requests, so a slow memory leak never grows into an outage.
request_slowlog_timeout
A request that runs longer writes its stack trace to the slow log, which names the function to look at.
request_terminate_timeout
Stops a runaway request before it holds a worker for good.
pm.status_path
Shows the queue and how often the pool hit its limit - the first place we look when a site slows at peak.

Two roads from the web server to PHP

We run both, and choose by what the site needs. Either way the fastest request is the one PHP never has to answer.

LiteSpeed + LSAPI

  1. Visitor
  2. LiteSpeed
  3. LSCachepage cache
  4. LSAPI
  5. lsphp

LiteSpeed talks to PHP over its own LSAPI protocol and keeps whole pages in LSCache, which WordPress and other applications can purge exactly when content changes.

Nginx + PHP-FPM

  1. Visitor
  2. Nginx
  3. fastcgi_cachepage cache
  4. FastCGI
  5. PHP-FPM

Nginx serves static files itself, passes PHP to a PHP-FPM pool over a local socket, and can keep finished pages in its FastCGI cache for visitors who are not logged in.

  • Redis for objects and sessions

    The answers a page asks the database for again and again are kept in memory, and sessions stop touching the disk.

  • A full-page cache

    A visitor who is not logged in gets a finished page without PHP or the database being asked at all. Carts, accounts and checkouts are kept out of it.

  • HTTP/2 and compression

    One encrypted connection carries every file of the page, text is compressed before it leaves, and static files carry long cache lifetimes with versioned names.

  • Database time

    The slow query log and EXPLAIN show which query waits and why; usually one index or one repeated query is most of it.

    How we tune databases

We measure first, then change one thing

Every change is made against a reading taken before it, so we can show you what it did. These are the readings.

  • opcache_get_status()Hit rate, free memory and whether scripts are being evicted.
  • pm.status_pathThe listen queue and how often the pool reached its limit.
  • slowlogThe stack trace of every request that ran too long.
  • slow_query_log + EXPLAINWhich queries wait, how many rows they read, and which index is missing.
  • redis-cli INFO statsCache hits against misses, and whether keys are being evicted.
  • curl -w %{time_starttransfer}The time to the first byte, taken the same way before and after.

Want the other half of the number - the distance from our servers to you? Run the speed test

Tell us which page is slow

Send us the address and where it runs today. We read the server first, then tell you what we would change and what it should do - and quote the work after that conversation.