A PHP upgrade fails for two reasons, and only one of them is PHP. The runtime change itself is ordinary: install the package, restart the service. What fails is the application's assumptions about the runtime — a deprecated call on a template nobody has opened in a year, an extension a plugin loads, a cache keyed to the version string — and those are found by rehearsal rather than by reading the migration guide carefully. This is the order that finds them before production does.
Step 1: enumerate what is actually serving traffic
The version on the command line is not the version serving requests, and on an estate built up over years they are frequently different binaries. Start with the installed binaries and work towards the socket each pool listens on, because the socket is what the web server talks to.
# Every installed runtime, not just the one that resolves on PATH
ls -1 /usr/bin/php* /usr/local/bin/php* 2>/dev/null
# What each one reports, and which SAPI it is — cli here does NOT mean
# the site is served by cli. It means this is the wrong binary to ask.
for b in /usr/bin/php*; do
printf '%s -> ' "$b"
"$b" -r 'echo PHP_VERSION, " ", PHP_SAPI, PHP_EOL;'
done
# Installed FPM services, then the pools and their sockets
systemctl list-units --type=service 'php*-fpm.service'
find /etc/php -path '*fpm/pool.d*' -name '*.conf' -exec grep -H '^listen\|^user\|^group' {} +
# Which FPM service is enabled right now
systemctl is-enabled php*-fpm.service
Then reconcile that against the web server configuration, because a vhost can be pointed at a socket that no pool listens on, or at a pool whose service is installed but disabled. Apache's mod_php is the other case worth naming explicitly: there, PHP is a module inside the web server rather than a service beside it, the version is not visible in the FPM inventory at all, and the upgrade is an Apache configuration change rather than a service restart. Whichever SAPI it is, record its identity alongside the version — the two together are what an advisory is actually about.
Step 2: classify the change and the versions on both sides
Write down the current branch, the target branch, and which of the two changes is being made. A patch within a branch takes the same major and minor version and is bounded by the fixes in that release. A branch move changes the language itself: deprecations become notices, notices become errors, and things removed in the target branch stop existing. The migration guide for the target major version is the authoritative list of what those are; the scan below is a first pass over the codebase, not a substitute for it.
Step 3: scan the codebase for what the target removed
Before touching a server, get a count of the work. A static pass over the application, plugins and themes finds the mechanical cases quickly, and the count is what tells you whether this is an afternoon or a project.
# Extend this list from the target branch's migration guide rather than
# from memory: it is a first pass, not an analysis.
grep -rInE '\b(create_function|each|money_format|get_magic_quotes_gpc|set_magic_quotes_runtime|ereg|split|mysql_(query|connect|fetch_array))[[:space:]]*\(' \
--include='*.php' --include='*.inc' --include='*.phtml' . | wc -l
# Separate first-pass searches (not a comprehensive compatibility scanner):
# ${var} string interpolation and the removed utf8_encode/decode functions.
grep -rInE '\$\{[a-zA-Z_]' --include='*.php' . | wc -l
grep -rInE '\butf8_(encode|decode)[[:space:]]*\(' --include='*.php' . | wc -l
The counts are not the finding. The finding is whether they are zero, whether they are in code you maintain or in a vendor directory you do not, and whether the files they are in are reachable. A deprecated call in an unmaintained plugin is a reason to remove the plugin; the same call in the theme's header template is a reason to schedule work. This is also the moment to list the loaded extensions and match them against the target branch, because an extension that has no build for the new version stops the upgrade regardless of what the code does.
Step 4: rehearse against a copy of the real application
A clean install of the new runtime passes everything, because there is no application on it. The rehearsal needs the real thing: the same plugins at the same versions, the same configuration, a copy of the database, and enough traffic shape to reach the paths a browser will. Two specific checks are worth building into it, because they are the failures that survive a functional review — a cache layer whose keys or serialisation changed between branches, and an opcache preload list that names classes the target branch does not have.
Where the application cannot be copied — licensing, size, or a plugin that phones home — say so before the window rather than during it, and reduce the change instead. Staying on the branch and taking the current security release within it is the smaller change, and for a site that cannot be rehearsed it is frequently the right one.
Step 5: cut over per pool, with the old runtime still installed
Keep both runtimes present through the change so that switching is a configuration edit rather than a deployment. That makes the rollback the same operation in reverse, and it is what keeps the window short — the application files do not move, only the socket the web server hands requests to.
# PHP-FPM: the pool already listens on its own socket for the new branch.
# Point one vhost at it, reload, and watch the log for that site only.
# Replace the socket name to match the pool configured on this host.
sed -i 's|php8\.1-fpm\.sock|php8.4-fpm.sock|' /etc/nginx/sites-enabled/example.conf
nginx -t && systemctl reload nginx
# mod_php: the runtime is an Apache module, so the switch is a module
# change plus an Apache reload, and there is no socket to fall back to.
# a2dismod php8.1 && a2enmod php8.4 && apache2ctl configtest && systemctl reload apache2
Do it a pool at a time where the estate allows it, in ascending order of consequence, and leave time between them. A pool that serves one low-traffic site is the cheapest place to discover that the new runtime emits a warning the old one swallowed. The failure mode to avoid is switching everything and then diagnosing: after a fleet-wide change, no single observation distinguishes a runtime fault from a coincidence.
Step 6: verify what is serving, not what was installed
Installation success is a statement about packages. The evidence that matters is a request served by the new runtime on the real hostname, with the application's own logs read afterwards. Four things, in this order: the version actually serving, the error rate against the same hour yesterday, the application's deprecation and warning log, and response times on the paths that carry traffic. Where the site has a health endpoint or an admin status screen that reports the runtime, that report is the primary evidence and the deployment log is not.
Remove the interim configuration before you leave. The mitigating settings applied while the update was pending — an unlimited memory budget, upload progress disabled, request-size limits tightened at the proxy — were measures for a window, and a window that never closes is a configuration nobody chose and nobody remembers choosing.