UPGRADE
PHP 8.4 upgrade checklist for a legacy application
Static analysis and Rector will do the mechanical work in a fortnight. What sinks these projects is the code that runs fine on the new version and quietly returns different answers.
The upgrade itself is rarely the problem. Static analysis and Rector will do most of the mechanical work in a fortnight. What sinks these projects is the code that runs fine on the new version and quietly produces different answers.
The successful versions of this project and the painful ones use broadly the same tools. What separates them is the order, and specifically whether anybody builds a safety net before they start changing code.
This is the order we work in, and why.
How these projects actually fail
Three failure modes, and none of them are about syntax.
Nobody could tell whether the output changed. The application ran, the pages loaded, no errors appeared, and six weeks later somebody noticed an invoice total had been wrong since the deploy. Without a regression suite there is no way to distinguish “it works” from “it runs”.
The upgrade got bundled with a refactor. Somebody saw a bad class while fixing a deprecation and improved it. Now when a bug appears, nobody knows if it came from the version bump or the improvement, and the bisect that would have told you in ten minutes is useless.
A feature freeze was promised and then broken. Development stops for the upgrade, the business cannot hold that position, features get shipped to main anyway, and the upgrade branch drifts until merging it is its own project.
Everything below is arranged to make those three impossible.
Before you change one line
Get a regression suite around the money paths. Not full coverage. The paths where a wrong number costs real money: checkout, invoicing, tax, commission, anything that writes a balance. Use the current system as the source of truth. Run inputs through it, record outputs, assert against those. You are not testing whether the behaviour is right. You are recording what it is.
On a codebase with no suite at all this is usually a week to ten days, and it is the only thing that makes the rest of the project safe. The coverage percentage matters less than a simpler test: every money path has something asserting its output before anything moves.
Pin your dependencies and read the diff. composer outdated --direct shows
what is behind. Plenty of “PHP 8.4 problems” turn out to be a dependency that
never supported anything past 7.4 and needs replacing, which is a different and
larger piece of work. Do this in week one, because a package with no maintained
successor can change the shape of the whole project and you want to know before
you have committed to a date.
Inventory the extensions, not just the packages. php -m on the production
server, compared against what your target version ships. This is where old
applications hide their surprises: ext-mysql, mcrypt, an ionCube-encoded
vendor library nobody has the source for, a custom extension somebody compiled
in 2016. Any one of those can turn a two-month project into a six-month one,
and none of them is visible in the codebase.
Get it into CI, even badly. A pipeline running the suite on the old version and the target version side by side turns the whole upgrade into a list of red things to make green. Without it you are testing by hand, and you will miss things. It does not need to be good CI. It needs to exist in week one.
Write down the target and the reason. “8.4 because 8.2 loses security support at the end of this year” is a decision. “The latest one” is a preference that will get argued about in week six when a dependency only supports 8.3.
The order we work in
1. Static analysis on the old version first
Run PHPStan at level 0 against the codebase as it stands, on the PHP version it currently runs. Fix that. Then level 1. Then 2.
This feels like a detour and it is not. Every level you clear on the old version is a class of runtime error that cannot surprise you on the new one. Most legacy codebases have real bugs sitting at level 2 that nobody has hit yet: a method called on a possibly-null value, an array key that is not always set, a function whose return type changes depending on the branch.
Use a baseline file, but use it honestly. Generating a baseline at level 5 on day one and calling the codebase level 5 is theatre. Ours holds only the things we have consciously decided not to fix yet, and it shrinks every week.
We aim for level 5 on inherited code before touching the version, and level 8 on anything we write. That is the standard on every project we run, not a target we set for upgrades.
2. Rector, in small passes
Rector handles the boring 80%. Run it one rule set at a time, commit after each, and read every diff.
// rector.php
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/src'])
->withPhpSets(php80: true) // one at a time, in order
->withPreparedSets(deadCode: true);
Do 7.4 to 8.0 completely, commit, deploy to staging, let it sit. Then 8.0 to
8.1. Do not jump straight to 8.4 in one run, because when something breaks you
will have no idea which version’s changes caused it, and the whole point of
committing per pass is that git bisect can answer the question in minutes.
Two rules about what you let it do:
- Do not enable the code quality or coding style sets during an upgrade. They produce enormous, plausible-looking diffs that mix mechanical changes with judgement calls. Run them later, as their own piece of work.
- Read the dead code diff especially carefully. Rector is good at spotting unreachable code and occasionally wrong about it, particularly around dynamic calls and anything reached only through a framework’s magic.
3. The deprecations that actually bite
Roughly in order of how often they turn up in real codebases.
String to number comparison changed (8.0). This is the big one, and it is
first on the list rather than in version order because it is the one that
changes answers instead of throwing errors. 0 == "foo" was true before 8.0
and is false now. That is a behaviour change inside conditionals. If you
have if ($status == 0) anywhere near a string status column, your logic just
changed underneath you and nothing will tell you. There is a section below on
how we hunt these.
Implicit nullable parameters (8.4). function f(int $x = null) is
deprecated; write ?int $x = null. Mechanical, high volume, and Rector handles
it completely. On an old codebase this is often the single largest count of any
deprecation and still only an afternoon of work.
Dynamic properties deprecated (8.2). $obj->somethingNeverDeclared = 1 now
warns. In older codebases this is everywhere, especially in anything hydrating
objects from a database row or building a view model on the fly.
#[\AllowDynamicProperties] is the escape hatch. Use it to get green, then
remove it class by class, because leaving it on permanently means you have
turned the warning off rather than fixed anything.
E_STRICT removed and error levels shifted (8.0). If your error handler
does bit arithmetic on error levels, check it. Silent log gaps are worse than
crashes, because you stop hearing about the problems rather than stopping having
them.
utf8_encode and utf8_decode removed (8.2). Almost always used
incorrectly in the first place, since they convert Latin-1 rather than “encoding
to UTF-8”. Every use is worth reading rather than mechanically replacing. The
correct replacement depends entirely on what the data actually is, and
mb_convert_encoding with the wrong source charset will mangle it silently.
Nested ternaries without parentheses (8.0). A hard error now. Easy to fix, easy to fix wrongly, so read each one and work out what it was meant to do before you add brackets.
ReturnTypeWillChange on interface implementations (8.1). Anything
implementing ArrayAccess, Iterator, JsonSerializable or Countable needs
either the real return types or the attribute. High volume in older code, easy
to fix, and Rector does most of it.
Callable strings and create_function remnants. Long gone, but they linger
in configuration arrays and event maps where static analysis does not reach.
Grep for them directly.
match and enum are not the problem. switch fallthrough is. Upgrades
often get bundled with refactors. Resist. Change the version, then refactor.
4. The library layer
Where the time goes on old applications, roughly in order of pain:
- A hand-rolled ORM or DB layer. Usually fine. Occasionally full of string-interpolated SQL that PHP 8’s stricter type juggling exposes, which is unpleasant but is also a security finding you are glad to have.
- An abandoned package. Fork it or replace it. Never patch
vendor/, because the nextcomposer installdeletes your fix and nobody will remember it existed. - Anything touching
ext-mysql. Gone since PHP 7. If you still have it, you have a bigger project than a version upgrade. - PDF, Excel and image libraries. Notoriously version-sensitive. Test the actual output, not just that the call returns. We compare generated files byte-for-byte against known-good ones from the old version, because a PDF that renders with a missing font still renders.
- Anything doing its own serialisation. Session payloads, cached objects, queued jobs. Serialised data written by the old version has to be readable by the new one during the deploy window, or you drop every job in the queue.
5. Branch by branch, never a big bang
We upgrade on a long-lived branch that rebases on main daily, and ship partial progress to staging constantly. Done that way an upgrade needs no feature freeze at all: the team keeps shipping and the upgrade branch keeps rebasing.
That is more work for us than freezing would be, and it is usually the reason these projects get approved. A two-month feature freeze is not something most businesses can absorb, so an upgrade that requires one tends not to happen.
The discipline that makes the rebase survivable: every commit on the upgrade branch does one thing, is named for the rule or deprecation it addresses, and touches as few files as it can. Conflicts then resolve mechanically instead of requiring somebody to reconstruct intent.
Finding the behaviour changes nothing tells you about
The comparison change deserves its own process, because no tool will flag it for you. It is not an error, a warning or a deprecation. It is a different answer.
What we actually do:
- Grep for loose comparisons against zero and empty string.
== 0,!= 0,== '',== false, andin_arraywithout a third argument. On a twelve-year-old codebase this returns hundreds of hits, most of them harmless. - Filter to the ones touching data of uncertain type. Anything comparing a database column, a request parameter, or a function that can return either a string or a number. Those are the candidates.
- For each candidate, decide the intended comparison and make it explicit
with
===or a cast. This is a real read of each line and it cannot be automated safely. - Where the volume is too high to read all of it, run the two versions side by side against the same inputs and diff the outputs. Shadow traffic is ideal if you can afford it. A batch job replaying yesterday’s requests through both and comparing responses catches what reading misses.
in_array without strict mode is the one that catches people most often.
in_array(0, ['a', 'b']) was true before 8.0. Any permission check or status
lookup written that way changed meaning on the version bump, and the direction
of the change is not always the safe one.
The deploy
By this point the risk is small, which is the whole point of the preceding eight weeks. Still worth doing properly.
- Canary one server first, or a small percentage of traffic. Watch error rates and response times for an hour before proceeding.
- Clear and warm OPcache deliberately. A cold cache on a busy application produces a spike that looks exactly like the upgrade breaking something.
- Keep the old version installed and one config change away. The fastest rollback is a switch, not a redeploy.
- Watch the error log for deprecations, not just errors. Set the log level to include them for the first week, then turn it back down. New deprecation volume is how you find the code paths your tests do not cover.
- Check the scheduled jobs the next morning. Cron and queue workers run on their own schedule and their failures are quiet. The first nightly billing run after an upgrade deserves somebody watching it.
What it costs
A jump of several major versions on an application with no test suite is typically two to three months of one engineer’s time. Roughly where that effort goes, and the shape holds across projects even when the total does not:
| Phase | Share of the work |
|---|---|
| Regression suite and CI | 30% |
| Static analysis and Rector | 20% |
| Dependency replacement | 25% |
| Behaviour differences and edge cases | 20% |
| Deploy and monitoring | 5% |
The actual version bump is the smallest line on that table. When somebody quotes an upgrade at two weeks, they have costed the Rector run and nothing else.
At our published PHP rates of $22 mid, $30 senior and $38 lead, a project of that size lands in the region of $18,000 to $30,000. A four-year-old Laravel application with existing tests is a fraction of that, sometimes a single week. The variable is almost entirely how much of the ten days of test writing you already have behind you.
We price these after a two-day audit at a fixed $800, because the extension inventory and the dependency diff are what determine the number and neither can be guessed from a description.
Three things worth doing while you are in there
Not a refactor. Three specific, cheap wins that get much harder later.
- Turn on
declare(strict_types=1)in new files only. Retrofitting it across a legacy codebase is a project of its own. Applying it to everything written from now on costs nothing and stops the bleed. - Add a coverage floor to CI. Whatever the number is when you finish, set the gate one point below it. Ours sits at 70% on business logic. It stops the suite decaying the moment the upgrade team leaves, which otherwise happens within two quarters.
- Record the upgrade in the repository. A short document naming the versions, the dependencies replaced, the deprecations suppressed rather than fixed, and where the baseline file is. The next upgrade starts from that document, and without it somebody repeats the archaeology.
When to stop
If the application is stable, unchanging and behind a firewall, “must be on the latest PHP” is not by itself a reason. Security support is, and it comes with dates you can plan against.
| Version | Security support ends |
|---|---|
| PHP 8.1 | 31 December 2025 |
| PHP 8.2 | 31 December 2026 |
| PHP 8.3 | 31 December 2027 |
| PHP 8.4 | 31 December 2028 |
Running on something with no security patches is a real risk with a real date attached, and it is the version of this argument that gets budget approved.
Upgrade because support ended, because a dependency you need requires it, or because you are about to build something new on top. Not because the number looks old.
Common questions
Can we skip versions and go straight to 8.4?
You can deploy straight to 8.4. You should not develop straight to it. Work through the version sets one at a time in the repository, committing each, then deploy the end result. The intermediate commits are what make a failure diagnosable.
How long does it take?
For a modern framework application with tests, one to two weeks. For a legacy application without tests, most of the time is the tests: budget nine to twelve weeks and expect a third of it to be the regression suite. The codebase’s age matters far less than whether it has a suite.
Do we need a feature freeze?
No, and we would push back if you offered one. Daily rebases onto main cost more engineering time and cost the business nothing, which is the right trade almost every time.
What if a dependency has no 8.x version at all?
Then you have two projects, and it is better to know in week one. The options are fork and maintain it yourself, replace it with something maintained, or wrap the functionality behind an interface and reimplement. We have done all three. Forking is the cheapest now and the most expensive over five years.
Is it worth doing at the same time as a framework upgrade?
No. One variable at a time. Do the PHP version, deploy it, let it settle for a fortnight, then do the framework. Bundling them is the single most common reason these projects lose the ability to diagnose their own failures.