Proving a PHP upgrade before you switch, with a rollback ready
Moving an app to a new PHP branch safely: install side by side, lint and scan the code, trace what traffic really loads, then switch one socket.

Most PHP upgrades fail the same way: the new version is installed over the old one, the homepage loads, everyone relaxes, and the first administrator to open a rarely used screen the next morning gets a white page. Here is the method we use to find those problems on the bench, before the cutover, with a way back that takes seconds.
Why upgrades keep breaking things
Each PHP branch removes functions, tightens type handling, and turns old warnings into fatal errors. An application that has grown over years carries code paths nobody has exercised in a long time: an export, a legacy payment module, an admin report. A smoke test of the public pages will never reach them.
Commercial applications add a second layer. Files protected by an encoder only run if the loader for the new PHP branch supports the format they were encoded with, and that is a property of each file, not of the application's marketing page.
Step one: install side by side, never over the top
Install the target PHP next to the current one. On RHEL and its rebuilds, Remi's software collections make this straightforward: each branch lives in its own prefix, with its own FPM service and socket. Create a pool for the site on the new version, identical to the old pool except for the binary and the socket path.
Nothing has changed for visitors yet. Both versions are running, and the web server still points at the old socket.
Step two: lint every file with the new binary
The cheapest test is to ask the new PHP to parse every file:
NEW_PHP=/path/to/new/php
find /var/www/app -name '*.php' -print0 \
| xargs -0 -n1 -P8 "$NEW_PHP" -l 2>&1 \
| grep -v '^No syntax errors'
This catches syntax that the new branch no longer accepts. It does not catch removed functions or changed behaviour, because those only fail at run time. For that, run a static compatibility scan, such as PHP_CodeSniffer with the PHPCompatibility standard, set to your target branch:
phpcs -p --standard=PHPCompatibility --runtime-set testVersion <target> \
--extensions=php /var/www/app
Expect noise from vendored libraries and dead code. The list is a map, not a verdict.
Step three: find out which files traffic actually loads
An application may contain tens of thousands of PHP files, of which a fraction ever run. You want the compatibility effort spent on that fraction, and you want to be sure it includes the logged in and administrative paths.
OPcache already knows. After a normal business day on the current version, including people logging in and administrators doing their work, ask it which scripts it has compiled:
<?php
// protect this file: admin network only, and delete it afterwards
$s = opcache_get_status(true);
foreach (array_keys($s['scripts']) as $f) { echo $f, "\n"; }
Save the list, compare it with the lint and scan results, and the problems that will actually be hit rise to the top. Files that appear in the scan but never in the trace are candidates for removal, not for fixing.
Step four: read the encoded files' headers
For encoded commercial software, check each file rather than trusting a compatibility banner. Encoded files carry a plain text header naming the encoder and the format:
grep -rl --include='*.php' 'ionCube Loader' /var/www/app \
| while read -r f; do head -c 400 "$f" | strings | head -3; done | sort | uniq -c
Group the files by encoder format, then confirm the loader for your target PHP branch runs that format. A module encoded years ago for an old branch is exactly the file that will refuse to load on the new one, and it is better to learn that from a header than from a customer.
Step five: rehearse on the new pool
Point a test hostname, or your own IP through a header rule, at the new socket while production stays on the old one. Walk the paths from the OPcache trace, with special attention to the ones only logged in users and administrators reach, and watch the new pool's error log while you do:
tail -F /var/log/php-fpm/app-new-error.log | grep -E 'Fatal|Uncaught|Deprecated'
Deprecation notices are not failures today, but they are the list for the next upgrade. Write them down.
Step six: switch one line, keep the old pool running
The cutover is one change in the web server: the FastCGI socket.
# fastcgi_pass unix:/run/php-fpm/app-old.sock;
fastcgi_pass unix:/run/php-fpm/app-new.sock;
nginx -t && systemctl reload nginx
Rollback is the same edit in reverse, applied in seconds, with no package changes and no restarts of anything but the web server's configuration. Agree the time with the site owner, watch error rates and response times for the first hour, and keep the old pool running through a burn-in period long enough to cover weekly and monthly jobs. Only then retire it.
On a multi node stack, do this one web node at a time behind the load balancer, so even a bad surprise reaches a fraction of traffic for a few minutes.
Step seven: keep cron and CLI in step
The web server is not the only thing running PHP. Scheduled jobs, queue workers and command line tools often call a hard coded binary path. Find them:
grep -rE 'php[0-9]*( |$)|/bin/php' /etc/cron* /var/spool/cron /etc/systemd/system 2>/dev/null
Move them to the new binary in the same change window, or deliberately after, but never by accident.
Your checklist
- New PHP installed alongside the old, with its own pool and socket.
- Every file linted with the new binary, and a compatibility scan for the target branch.
- An OPcache trace of what real traffic loads, including logged in and admin paths.
- Encoded files grouped by format and matched against the new loader.
- A rehearsal on the new pool with its error log open.
- A one line cutover, the old pool kept until burn-in ends, and cron moved on purpose.
If you have an upgrade you have been putting off, we are glad to plan it with you.
Published 22 October 2026.
All field notes