What is it? #
PHP on a modern server runs as PHP-FPM: a pool of PHP processes that a web server passes requests to.
Nginx does not execute PHP itself. It forwards matching requests to PHP-FPM over a socket and returns the response, which is why the two are configured together.
The pieces that matter for production are the extensions your framework needs, the process pool size, and OPcache.
OPcache in particular is not optional. Without it, PHP recompiles every file on every request, which typically costs more performance than any other single setting.
Think of it like this #
A kitchen with a fixed number of chefs. Orders arrive at the counter and are handed to whichever chef is free.
Too few chefs and orders queue. Too many and the kitchen runs out of space. And if every chef re-reads the recipe from scratch for every order, everything is slower than it needs to be.
Simple example #
A Laravel application on a 4 GB server. You install PHP-FPM with the required extensions, configure Nginx to pass PHP requests to it, size the pool for the available memory, and enable OPcache.
Code #
# Install a specific PHP version with the usual extensions
sudo add-apt-repository ppa:ondrej/php
sudo apt update
sudo apt install php8.3-fpm php8.3-cli php8.3-mysql php8.3-pgsql \
php8.3-mbstring php8.3-xml php8.3-curl php8.3-zip php8.3-gd \
php8.3-intl php8.3-bcmath php8.3-opcache
php -v
systemctl status php8.3-fpm
ls /run/php/ # php8.3-fpm.sock — the socket Nginx will use
# Nginx passes PHP requests to FPM
server {
listen 443 ssl http2;
server_name example.com;
root /srv/app/public; # the public directory ONLY
index index.php;
client_max_body_size 20M;
location / {
try_files <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>u</mi><mi>r</mi><mi>i</mi></mrow><annotation encoding="application/x-tex">uri</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6595em;"></span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">i</span></span></span></span>uri/ /index.php?$query_string; # front controller
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 60s;
}
location ~ /\.(?!well-known) { deny all; } # no dotfiles
}
; /etc/php/8.3/fpm/pool.d/www.conf — sizing the pool
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
pm = dynamic
pm.max_children = 20 ; total memory available / memory per process
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500 ; recycle workers to contain memory leaks
; Estimate max_children: (available RAM - other services) / avg process size
; e.g. (4096MB - 1500MB) / 120MB ≈ 21
; /etc/php/8.3/fpm/conf.d/99-production.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; production: never re-check files
; you MUST reload FPM on every deploy
display_errors=Off
log_errors=On
error_log=/var/log/php/error.log
memory_limit=256M
upload_max_filesize=20M
post_max_size=20M
expose_php=Off
# Composer, for dependencies
sudo -u appuser composer install --no-dev --optimize-autoloader
sudo systemctl reload php8.3-fpm # required when validate_timestamps=0
How it works #
Nginx matches requests ending in .php and forwards them to the FPM socket. Everything else is served as a file or routed to index.php by try_files, which is the front controller pattern every PHP framework uses.
The root points at the public directory, not the project root. This is the single most important line for security: it means .env, vendor code and configuration are outside the web-accessible tree.
pm.max_children is the maximum number of PHP processes. Setting it too high means memory exhaustion under load; too low means requests queue. The calculation in the comment — available memory divided by average process size — is the practical way to choose it.
pm.max_requests recycles each worker after a number of requests, which contains slow memory leaks in application or extension code.
OPcache stores compiled bytecode in memory so PHP does not re-parse files on every request. On a typical application this is a large improvement, often several times faster.
validate_timestamps=0 stops OPcache checking whether files changed, which removes a filesystem check per file per request. The consequence is that deployed code changes are invisible until FPM is reloaded, so the deployment must include that reload.
display_errors=Off matters in production: error output can reveal file paths, database details and code fragments.
Real-world use #
PHP-FPM behind Nginx is the standard modern arrangement, and it replaced the older Apache module approach in most new deployments.
Forgetting the FPM reload after a deployment with validate_timestamps=0 produces a confusing incident: new code is on disk and the old code keeps running.
Separate pools per application are common on shared servers, each running as its own user, so one application cannot read another's files.
The public directory rule prevents the most common serious PHP misconfiguration. Serving the project root exposes .env files, which usually contain database credentials and application keys.
Laravel, Symfony and WordPress all have specific recommended extension sets and cache warm-up steps; the deployment lesson later in this track covers the ordering.
Common mistakes #
- Pointing the web root at the project root instead of the public directory.
- Not enabling OPcache, losing a large amount of performance for nothing.
- Forgetting to reload FPM after deploying with validate_timestamps=0.
- Setting pm.max_children too high and exhausting memory under load.
- Leaving display_errors on in production and leaking internal details.
Practice #
Install PHP-FPM with the extensions a framework of your choice needs, configure Nginx to serve only the public directory, and enable OPcache with timestamp validation off. Then deploy a change and confirm it does not take effect until you reload FPM.