How we upgrade without changing behavior
Find out what will break before changing anything
We start with a copy of production, code and data, running in containers on both the current PHP version and the target. Static scanners such as PHPCompatibility and PHPStan list every use of a removed function, a changed signature or a retired extension. Then we turn error reporting all the way up on the old version and read the deprecation notices the application has been hiding for years. The output is an inventory with counts, which turns "how long will it take" from a guess into an estimate.
Tests first, because there usually are none
Most legacy PHP has no automated tests, and you cannot safely change what you cannot check. So before the upgrade we write characterization tests: scripts that sign in, request the important pages, submit the important forms and store the responses and database changes as the expected result. Their job is to detect change, with no opinion on whether the old behavior was good. The same suite then runs against the upgraded code, and every difference is either a bug we introduced or a deliberate fix we can explain.
Let tools do the mechanical work
Rector rewrites code by rule: removed functions with direct replacements, old-style constructors, curly-brace string offsets, implicitly nullable parameters. We apply one rule set per commit so each change can be reviewed and reverted on its own. What tools cannot decide is left to engineers, and the largest item there is usually database access. The mysql_* functions no longer exist in the language. A search-and-replace to mysqli keeps the old injection risks, so we route queries through PDO with bound parameters, working table by table.
PHP 8 changes that bite quietly
Fatal errors are the easy part because they announce themselves. Behavior changes are harder. Comparing a number with a non-numeric string gives a different answer in PHP 8. Built-in functions that used to return null with a warning for bad input now throw. Arithmetic on a non-numeric string stops the script. Code that relied on the old leniency, often in validation and pricing logic, keeps running and produces different results. This is exactly what characterization tests exist to catch.
Cutover and fallback
We upgrade in hops instead of one leap, deploying each stable step where hosting allows. The final switch is rehearsed on staging with a fresh data copy and scheduled for your quietest hour. The old environment stays intact until you sign off. From then on, staying current costs far less than catching up, which is where a PHP maintenance plan comes in.