Deploying PHP
Running PHP in production: web server and PHP-FPM, OPcache, production php.ini settings, logging, file permissions, upgrading PHP versions, and keeping applications patched.
How PHP runs on a server
On your own machine, php -S starts the
built-in server, which handles one request at a time and is meant for development
only. In production a real
web server
accepts the connections, handles TLS, serves images and style
sheets, and hands only the PHP requests to PHP. How it hands them
over is the first deployment decision.
FastCGI and PHP-FPM
FastCGI is a protocol between a web server and a separate, long-running application process: the web server forwards the request (headers, body and variables such as the script path) over a socket and gets the response back. It improves on the older CGI, which started a new process for every request. PHP-FPM (FastCGI Process Manager) is PHP's FastCGI server: a master process that starts and supervises a pool of worker processes, each handling one request at a time. Nginx, Apache, Caddy and others can all talk to it.
Running PHP in its own processes has practical advantages. Each pool can run as its own Linux user, with its own settings, so two applications on one server cannot read each other's files. PHP can be restarted, upgraded or given more workers without touching the web server, and two PHP versions can run side by side during an upgrade. As of 2026, PHP-FPM is the standard setup for Nginx and for current Apache configurations.
mod_php
mod_php is the older model: PHP loaded as a module inside the Apache web server, so every Apache process contains a PHP interpreter. It is simple to set up and still common on shared hosting and small servers. Its drawbacks: in practice it needs Apache's prefork mode (one process per connection; the usual non-thread-safe builds cannot run under a threaded mode), every process carries PHP's memory even when it only serves an image, and all sites run as the Apache user. For a new server, prefer Apache's event mode with PHP-FPM.
Application servers
A newer model keeps the application loaded between requests instead of starting each one from scratch. FrankenPHP, an application server built on the Caddy web server, has a worker mode that does this; it has been officially supported by The PHP Foundation since May 2025. RoadRunner and Laravel Octane offer similar models. They can be much faster, but the code must not keep request data in global or static variables, because the next request would see it.
Configuring Nginx and PHP-FPM
The Nginx side
The web root points to the application's
public/ folder, never to the project folder itself.
Requests for files that exist are served directly; everything else
goes to the front controller index.php, which routes it
in PHP:
server {
listen 443 ssl;
server_name portal.example.com;
# ssl_certificate lines left out; see HTTPS and TLS in practice
root /var/www/portal/current/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404; # only run PHP files that exist
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_pass unix:/run/php/php8.4-fpm-portal.sock;
}
location ~ /\.(?!well-known) {
deny all; # .env, .git and other dot files
}
}
try_files $uri =404 in the PHP block makes sure Nginx
only passes scripts that really exist, so an uploaded
photo.jpg can never be run as PHP through a crafted
URL. $realpath_root resolves the
current symbolic link used for releases (see below), so
PHP and OPcache see the real release folder. Certificates are
covered in
HTTPS and TLS in practice.
With Apache and PHP-FPM, the proxy_fcgi module does the
same job: a
SetHandler "proxy:unix:…|fcgi://localhost" line for
.php files. On Debian and Ubuntu, the
php8.4-fpm package ships a ready-made Apache
configuration snippet for this.
The PHP-FPM pool
Each application gets its own pool file. On Debian-based systems
these live in /etc/php/<version>/fpm/pool.d/:
[portal]
; Workers run as their own user, not as the web server's user
user = portal
group = portal
; Nginx (www-data) may connect to the socket
listen = /run/php/php8.4-fpm-portal.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; Process manager: between 4 and 12 idle workers, at most 51 in total
pm = dynamic
pm.max_children = 51
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
; Replace a worker after 500 requests, which contains slow memory leaks
pm.max_requests = 500
; Log requests that take longer than 5 seconds, with a stack trace
request_slowlog_timeout = 5s
slowlog = /var/log/php/portal-slow.log
php_admin_value[error_log] = /var/log/php/portal-error.log
php_admin_flag[log_errors] = on
php_admin_value sets a php.ini value for this pool
only, and the application cannot change it with
ini_set(). Check the configuration before you reload,
then reload. A reload replaces all workers; running requests only
get time to finish if process_control_timeout in
php-fpm.conf is set (for example to 10s),
because its default of 0 lets PHP-FPM stop them almost immediately
(systemd itself is explained in
services and systemd):
sudo php-fpm8.4 -t # test the configuration files
sudo systemctl reload php8.4-fpm
sudo nginx -t && sudo systemctl reload nginx
How many workers?
pm.max_children is the most important number. Each
worker handles one request at a time, so it is also the maximum
number of PHP requests in progress. When all workers are busy, new
requests wait in a queue, and users see a slow site or a
502/504 error from Nginx. Too many workers
is worse: the server runs out of memory and starts swapping or
killing processes. The usual starting point is the memory available
for PHP divided by the average memory of one worker, measured on
your own application:
<?php
// Sizing PHP-FPM for a 4 GB server that also runs Nginx and monitoring
$totalMb = 4096;
$reservedMb = 1024; // operating system, Nginx, monitoring, headroom
$averageWorkerMb = 60; // measured: average memory of one PHP-FPM worker
$maxChildren = intdiv($totalMb - $reservedMb, $averageWorkerMb);
echo "pm.max_children = $maxChildren\n";
// What happens when a busy hour brings 80 simultaneous requests?
$concurrent = 80;
$waiting = max(0, $concurrent - $maxChildren);
echo "$maxChildren requests run, $waiting wait in the queue\n";
pm.max_children = 51 51 requests run, 29 wait in the queue
A queue of 29 is fine if requests take 50 milliseconds, and a
problem if they take 5 seconds. Faster requests, through caching or
fixing slow queries, often help more than extra workers. The PHP-FPM
log warns when the pool reaches
max_children; that warning means it is time to look.
OPcache
Without help, PHP would read, parse and compile every script on
every request. OPcache is the
built-in extension that stores the compiled form of each script (its
bytecode, called opcodes in PHP) in shared memory, so all workers reuse it
and later requests skip the compile step. It is the single biggest
performance setting, and it is on by default for web requests
(opcache.enable=1) but off on the command line
(opcache.enable_cli=0). Since PHP 8.5 it is always
compiled into PHP; before that it is a separate extension that most
packages enable.
opcache.enable = 1
; Memory for compiled scripts in MB (default 128)
opcache.memory_consumption = 256
; Raise above the number of PHP files, vendor/ included (default 10000)
opcache.max_accelerated_files = 20000
; Do not check files for changes on every request; reload PHP-FPM after a deploy
opcache.validate_timestamps = 0
By default OPcache checks every two seconds whether a file has
changed (validate_timestamps=1,
revalidate_freq=2). In production the code changes only
at a deployment, so many teams switch the check off and reload
PHP-FPM as the last step of each deployment. Forget the reload, and
the server keeps running the old code.
Production php.ini settings
PHP ships two templates: php.ini-development shows
errors on screen, php.ini-production hides them and
logs them. A production server starts from the production file, with
these points checked:
; Errors: everything reported, nothing shown, everything logged
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log
; Do not announce the PHP version in an X-Powered-By header
expose_php = Off
; Limits per request
memory_limit = 256M
max_execution_time = 30
upload_max_filesize = 10M
post_max_size = 12M
; An explicit time zone (when unset, PHP silently uses UTC)
date.timezone = UTC
display_errors=Off matters most: an error message in
the page shows visitors file paths, SQL fragments and sometimes
passwords from a connection string. Set
error_reporting=E_ALL (the production template leaves
out E_DEPRECATED), so deprecation warnings reach the
log well before an upgrade turns them into errors (see
errors and exceptions). The
session and security settings are listed in
PHP security basics.
Which php.ini?
The command line and PHP-FPM read different files, which
regularly confuses people who change a setting and see no effect. On
Debian and Ubuntu each one has its own folder, plus a
conf.d/ folder whose files are read after the main
file:
| Used by | Main file (PHP 8.4) | Extra files |
|---|---|---|
| Command line, cron jobs | /etc/php/8.4/cli/php.ini |
/etc/php/8.4/cli/conf.d/ |
| PHP-FPM | /etc/php/8.4/fpm/php.ini |
/etc/php/8.4/fpm/conf.d/ |
| Apache with mod_php | /etc/php/8.4/apache2/php.ini |
/etc/php/8.4/apache2/conf.d/ |
php --ini lists the files the command line loads; for
PHP-FPM, check the pool's values from inside a request, or
temporarily with phpinfo() on a protected URL (and
remove it afterward). A small script can compare the values that
matter with what production should have. Here the current values are
a fixed array; on a server they would come from
ini_get() in a request:
<?php
// Values as found on a server (in a request: ini_get('display_errors') and so on)
$current = [
'display_errors' => '1',
'display_startup_errors' => '0',
'log_errors' => '1',
'error_reporting' => (string) E_ALL,
'expose_php' => '1',
'session.cookie_secure' => '0',
'opcache.enable' => '1',
];
// What production should look like
$expected = [
'display_errors' => '0',
'display_startup_errors' => '0',
'log_errors' => '1',
'error_reporting' => (string) E_ALL,
'expose_php' => '0',
'session.cookie_secure' => '1',
'opcache.enable' => '1',
];
foreach ($expected as $name => $want) {
$have = $current[$name] ?? '(not set)';
$ok = $have === $want;
printf("%s %-24s %s\n", $ok ? 'ok ' : 'FIX ', $name, $ok ? $have : "$have, expected $want");
}
FIX display_errors 1, expected 0 ok display_startup_errors 0 ok log_errors 1 ok error_reporting 32767 FIX expose_php 1, expected 0 FIX session.cookie_secure 0, expected 1 ok opcache.enable 1
(E_ALL is 32767 on PHP 8.2 and 30719 from PHP 8.4, when
E_STRICT was dropped.)
Logging
A PHP application in production writes to several logs, and during an incident you need to know which one to read:
-
The PHP error log (
error_login php.ini or in the pool): warnings, uncaught exceptions, fatal errors. - The PHP-FPM log: workers that crashed or were killed, "server reached max_children", and the slow log.
-
The web server logs: every request with its
status code in the access log;
502 Bad Gatewayand timeouts between Nginx and PHP-FPM in the error log. - The application's own log: events you log on purpose, often through a PSR-3 logger such as Monolog.
Visitors get a short, generic message; the log gets the details. Structured logs, one JSON object per line, are easy to search and to send to a central log system:
<?php
$logFile = tempnam(sys_get_temp_dir(), 'app-log-');
function log_event(string $file, string $level, string $message, array $context = []): void
{
$entry = [
// A fixed time for this example; normally new DateTimeImmutable('now')
'time' => (new DateTimeImmutable('2026-10-14 07:42:10', new DateTimeZone('UTC')))->format(DATE_ATOM),
'level' => $level,
'message' => $message,
'context' => $context,
];
// Message type 3: append to the given file
error_log(json_encode($entry, JSON_UNESCAPED_SLASHES) . "\n", 3, $file);
}
try {
throw new RuntimeException('Connection refused by billing-db:5432');
} catch (RuntimeException $e) {
log_event($logFile, 'error', $e->getMessage(), ['request' => '/invoices/2026-10', 'user_id' => 1042]);
echo "Something went wrong. Please try again later.\n"; // what the visitor sees
}
echo file_get_contents($logFile); // what the log keeps
unlink($logFile);
Something went wrong. Please try again later.
{"time":"2026-10-14T07:42:10+00:00","level":"error","message":"Connection refused by billing-db:5432","context":{"request":"/invoices/2026-10","user_id":1042}}
Never log passwords, session IDs, tokens or full card numbers, and think twice about personal data. Log files grow without limit unless they are rotated, see log rotation; and a log nobody reads is only a record for the incident response afterward. Alerts on error rates and on the max_children warning turn logs into early warnings.
File layout and permissions
A deployed application keeps code, secrets and writable data apart.
This layout keeps each release in its own folder and points a
current symbolic link at the active one:
/var/www/portal/ ├── releases/ │ ├── 2026-10-01-1405/ │ └── 2026-10-02-0930/ │ ├── public/ ← web root: index.php, css, images │ ├── src/ │ ├── vendor/ │ ├── composer.json │ └── composer.lock ├── shared/ │ ├── .env ← secrets: never in Git, never in public/ │ └── storage/ ← uploads, cache, sessions: writable by PHP └── current -> releases/2026-10-02-0930
-
Only
public/is reachable. Configuration,vendor/,.envand.gitmust never be under the web root, or a misconfiguration makes them downloadable. -
Code is read-only for PHP. The files belong to a
deploy user; the PHP-FPM user (
portal) can read but not change them. If an attacker finds a way to write files through the application, they cannot plant PHP code in it. -
Only
storage/is writable, and nothing in it is ever run as PHP. - Secrets are readable by the deploy user and the PHP group only, and come from the environment or a file outside the repository, never from Git.
sudo chown -R deploy:deploy /var/www/portal/releases
sudo chmod -R u=rwX,go=rX /var/www/portal/releases # code: read-only for everyone else
sudo chown deploy:portal /var/www/portal/shared/.env
sudo chmod 640 /var/www/portal/shared/.env # secrets: owner and PHP group only
sudo chown -R portal:portal /var/www/portal/shared/storage
sudo chmod -R u=rwX,g=rX,o= /var/www/portal/shared/storage
The capital X gives execute (enter) permission to
folders only, not to files. The permission bits themselves are
explained in
users, groups and permissions.
Deploying a new release
Copying changed files over a running site with FTP leaves a window
in which half the files are new and half are old, and there is no
quick way back. A release-folder deployment avoids both: build the
new release next to the running one, then switch the
current link in one step. An automated pipeline runs
the same steps (see
deployment pipelines):
release=/var/www/portal/releases/$(date +%Y-%m-%d-%H%M)
# 1. Get exactly the tested version
git clone --depth 1 --branch v2.4.0 git@git.example.com:kestrel/portal.git "$release"
# 2. Install dependencies from composer.lock, without development tools
cd "$release"
composer install --no-dev --optimize-autoloader --no-interaction
# 3. Link the shared secrets and writable folders into the release
ln -s /var/www/portal/shared/.env "$release/.env"
ln -s /var/www/portal/shared/storage "$release/storage"
# 4. Switch atomically: a new link, renamed over the old one
ln -sfn "$release" /var/www/portal/current.new
mv -T /var/www/portal/current.new /var/www/portal/current
# 5. Let PHP-FPM pick up the new code (clears OPcache)
sudo systemctl reload php8.4-fpm
-
composer installinstalls exactly the versions incomposer.lock; never runcomposer updateon a server.--no-devleaves out test tools such as PHPUnit, which do not belong in production. - Database migrations need care: a change that the old code cannot handle breaks the site from the moment the migration runs until PHP-FPM is reloaded, and blocks a rollback. Make schema changes backward compatible first.
-
Rolling back is the same switch, pointing
currentat the previous release folder, followed by a reload. Keep the last few releases for that reason.
Upgrading PHP
Each PHP branch gets two years of bug and security fixes, then two years of security fixes only. End of life is the date after which a PHP branch gets no more fixes from the PHP project at all, not even for security problems that are found later. A server on an end-of-life version accumulates known, unfixed vulnerabilities. The current support dates are in the table on modern PHP; a small script can track them for your servers:
<?php
// Security support per branch, from php.net/supported-versions (October 2026)
const SECURITY_UNTIL = [
'7.4' => '2022-11-28',
'8.1' => '2025-12-31',
'8.2' => '2026-12-31',
'8.3' => '2027-12-31',
'8.4' => '2028-12-31',
'8.5' => '2029-12-31',
];
function support_status(string $version, DateTimeImmutable $today): string
{
$branch = implode('.', array_slice(explode('.', $version), 0, 2));
if (!isset(SECURITY_UNTIL[$branch])) {
return 'unknown branch: check php.net';
}
$end = new DateTimeImmutable(SECURITY_UNTIL[$branch]);
if ($today > $end) {
return 'END OF LIFE since ' . $end->format('j M Y');
}
$days = $today->diff($end)->days;
return "supported until {$end->format('j M Y')} ($days days left)";
}
$today = new DateTimeImmutable('2026-10-02');
foreach (['7.4.33', '8.1.34', '8.2.30', '8.4.26', '8.5.11'] as $version) {
printf("PHP %-7s %s\n", $version, support_status($version, $today));
}
PHP 7.4.33 END OF LIFE since 28 Nov 2022 PHP 8.1.34 END OF LIFE since 31 Dec 2025 PHP 8.2.30 supported until 31 Dec 2026 (90 days left) PHP 8.4.26 supported until 31 Dec 2028 (821 days left) PHP 8.5.11 supported until 31 Dec 2029 (1186 days left)
Reality lags behind. W3Techs (October 2026) counts 64.3% of PHP websites on PHP 8, 27.8% on PHP 7 and 7.8% on PHP 5: more than a third run a major version that has had no upstream fixes for years. Linux distributions complicate the picture. Debian and Ubuntu ship one PHP version per release and may keep backporting security fixes to it for the life of that distribution release, after php.net has stopped. In that case the distribution's security support is what counts; check it rather than assume it. Newer PHP versions than the distribution ships usually come from a third-party repository, such as the widely used one by Ondřej Surý.
An upgrade, step by step
-
Inventory. Which servers, applications, cron jobs
and command-line tools use PHP, and which extensions they need
(
php -m). - Read the migration guides on php.net for every version you skip; each lists incompatible changes and deprecations.
- Check the code. Run the tests and PHPStan on the new version in CI, and read the deprecation warnings in the current production log. Check that frameworks, CMS and plugins support the target version.
-
Install side by side. On Debian-based systems,
php8.5-fpmand the matching extension packages (php8.5-mbstring,php8.5-mysql, …) can run next to 8.4, each with its own pool and socket. Copy over your settings; do not copy the old php.ini blindly. -
Switch and watch. On staging first, then in
production: point
fastcgi_passat the new socket, reload Nginx, and watch the error log and response times. Rolling back is pointing it at the old socket again. -
Clean up the old version once the new one has run
for a while, including the command-line default (
update-alternatives --config phpon Debian) so cron jobs do not keep using the old version.
Keeping applications patched
Most PHP incidents start in the application and its dependencies
rather than in PHP: an outdated CMS, a vulnerable plugin, an
abandoned library. Treat them like any other software in
patch management: keep an inventory with versions, follow the security
announcements of the CMS and framework you run, and apply security
releases quickly. WordPress installs minor core security releases
automatically by default; plugins and themes need their own policy.
For Composer projects, run composer audit in the
pipeline and on a schedule (see
Composer and packages), and
remove plugins and packages nobody uses: unused code can still be
attacked.
A production checklist
- A supported PHP version, with a planned upgrade date before its end of life.
-
Nginx or Apache with PHP-FPM; one pool and one Linux user per
application;
pm.max_childrensized from measured memory. - OPcache on, sized for the code base; PHP-FPM reloaded on every deployment.
-
display_errors=Off,log_errors=On,error_reporting=E_ALL,expose_php=Off; secure session settings. -
Web root is
public/; code read-only for PHP; one writable storage folder; secrets outside Git and the web root. -
Releases deployed from a tagged version with
composer install --no-dev, switched atomically, with a tested rollback. -
Logs rotated and watched;
composer auditand CMS updates on a schedule; no Xdebug, nophpinfo()pages left behind.