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.

A browser sends an HTTPS request to Nginx. Nginx serves static files such as CSS and images itself, and passes PHP requests over FastCGI to a PHP-FPM pool. In the pool, a master process manages several worker processes, which share OPcache: compiled scripts in shared memory. The response travels back the same way. Browser HTTPS Nginx web server serves css, images directly FastCGI PHP-FPM pool master process worker 1 worker 2 worker … OPcache: compiled scripts
Nginx handles connections and static files; each PHP request occupies one PHP-FPM worker until the response is complete. The workers share one OPcache.

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_log in 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 Gateway and 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/, .env and .git must 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 install installs exactly the versions in composer.lock; never run composer update on a server. --no-dev leaves 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 current at 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

  1. Inventory. Which servers, applications, cron jobs and command-line tools use PHP, and which extensions they need (php -m).
  2. Read the migration guides on php.net for every version you skip; each lists incompatible changes and deprecations.
  3. 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.
  4. Install side by side. On Debian-based systems, php8.5-fpm and 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.
  5. Switch and watch. On staging first, then in production: point fastcgi_pass at the new socket, reload Nginx, and watch the error log and response times. Rolling back is pointing it at the old socket again.
  6. Clean up the old version once the new one has run for a while, including the command-line default (update-alternatives --config php on 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_children sized 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 audit and CMS updates on a schedule; no Xdebug, no phpinfo() pages left behind.