DuskByte

Upgrading PHP When There Is No Test Suite

September 1, 2026 · 8 min read

Security support has ended, the operating system will not carry you, and someone external is asking questions. Here is why the upgrade lands on your desk, and how to find what it broke when the codebase has no tests: run the old and new runtimes in parallel and compare what they produce.

01-lock-in-chain.png
Changing the PHP version in a Dockerfile takes about a minute. Getting the application to start again might take an afternoon. Then you are at the actual beginning of the project, which is finding everything that now behaves differently and does not announce itself.

Before that, though, a fair question. If the thing has run for a decade and still makes money, why touch it at all?

Nobody upgrades PHP because they want to

In my experience it is never curiosity. It is one of four things, and usually more than one at once.

Security support ended, quietly, a while ago.

02-php-support-status.png
PHP gives each version two years of active support and two more of security fixes. As of now, only 8.2, 8.3, 8.4 and 8.5 receive patches. PHP 8.1 stopped on 31 December 2025. PHP 8.2 stops on 31 December 2026. PHP 7.4, which is still extremely common in the wild, has been unpatched for nearly four years, and the CVEs did not stop arriving when the patches did. Nothing visible happens on the day support ends, which is exactly why it gets missed.

The operating system stops carrying you.

This is the one that surprises people, and it is the hardest to argue with. PHP 5.x, 7.x and 8.0 cannot be compiled against OpenSSL 3.0.

Ubuntu 20.04 was the last release to ship OpenSSL 1.1.1; 22.04 moved to 3.0, and Debian did the same between 11 and 12. So an application pinned to PHP 7.4 is also pinned to Ubuntu 20.04, whose own standard support ended in May 2025. You are now maintaining two unsupported layers instead of one, and every route out of that runs through the PHP upgrade. Meanwhile Ubuntu 24.04 ships PHP 8.3 as its default, so a fresh, supported server will not run your application at all.

I have seen teams try to escape this by compiling an old OpenSSL alongside the new one. It works. It also means you now maintain a hand-built cryptography library on a production server, which is a worse position than the one you were trying to leave.

The ecosystem moves without asking.

Frameworks and Composer packages drop old PHP versions as a matter of routine. The moment a library you depend on ships a security fix in a release that requires a newer PHP, your options narrow to upgrading, backporting the patch yourself, or accepting the vulnerability. Extensions have the same problem, and so do hosting providers, several of which have removed old PHP entirely.

Someone external asks the question.

This is the one that actually gets budget approved. Unsupported runtime versions are the easiest finding a penetration tester will ever write: it takes one banner grab, it is not arguable, and it appears near the top of the report.
Vulnerability management is a requirement under PCI DSS, SOC 2 and ISO 27001, and software that cannot receive patches cannot satisfy it.
Cyber insurance questionnaires now ask about end-of-life software directly. Enterprise procurement asks during vendor review.
_
We have worked with large enterprise clients where the entire driver for modernisation was passing a security audit. Not features. Not performance. A report with a finding on it, and a date._

If you are the person who has to justify this internally, the last two are your argument. The first two explain why it is urgent. And the cost only moves one way: every year you wait, the version gap widens, more of the ecosystem leaves you behind, and the work you are about to read about gets larger.

Why reading the code does not find the problems

So the upgrade is happening. If the codebase has a decent test suite, this is manageable. Run the tests, fix what goes red, repeat.

Most applications in this position do not have one. That is not an accusation. Code written in 2013 by a small team under deadline pressure, that then earned money for a decade, is code that got tested by being used. The fact that it is still running is evidence the decisions were reasonable at the time.

Everyone tries reading first. Grep for the removed functions, run a compatibility checker, fix what it flags. Do that. It is worth an afternoon and it finds the loud problems: each(), create_function(), curly brace string offsets, the last surviving mysql_ calls.

But those are the things that stop the program. The upgrades that hurt are the ones where the program carries on happily and produces a different answer.

The clearest example is comparison. In PHP 7, 0 == "some string" evaluated to true, because the string was coerced to a number. In PHP 8 it evaluates to false, because the comparison is done as strings instead. The change is correct and well documented, and it is exactly the kind of thing sitting inside a decade-old permission check, coupon validator or status comparison. Nothing errors. A user who could not see a page now can, or a discount that should apply now does not.

You will not find that by reading, because the line looks fine. It has always looked fine.

The same goes for several others that only surface at runtime. curl and gd handles became objects rather than resources in PHP 8, so code branching on is_resource() silently takes the other path. PCRE2 arrived in 7.3 and rejects some patterns the old engine tolerated. Sorting became stable in PHP 8, which is an improvement and also changes output ordering. Float to string conversion became locale-independent. Notices became warnings, and some warnings became errors.

None of these announce themselves. All of them change what a customer sees.

The method: compare, do not inspect

If you cannot reason about the difference, measure it.
03-differential-method.png
Stand up two environments. One is production as it actually is: the old PHP, the old web server, the old operating system, the old extension versions. Not an approximation. The other is the target: current PHP, current web server, current database.

Point them at the same data. Send the same request to both. Compare what comes back.

That is the whole idea, and its power is that it assumes nothing about the code. It does not care whether the application is well structured, documented or comprehensible. It only cares whether the two versions agree.

Docker makes this practical in a way it was not ten years ago. Building an image with PHP 5.6 and the extensions of the era is fiddly but finite, and once it exists you have something worth more than any document: a reproducible copy of the thing you are replacing.

The part most people get wrong
04-three-buckets.png
Comparing the HTTP response body is the obvious move and it is not enough. A request does more than return a page. It writes rows, sends emails, pushes jobs onto queues, calls third parties, writes logs.

Two consequences follow.

You cannot run write requests against a shared database. Both stacks would write, and you would be comparing an application against itself after interference. Give each environment its own copy of the same starting data, run the request against both, then diff the two databases afterwards. The row-level differences are often where the real breakage is, and they never appear in the response body.

You also have to capture side effects. Point both environments at a mail catcher rather than a mail server. Record outbound HTTP instead of making it. Compare the queue payloads. A request that renders identically while pushing a subtly different job is exactly the failure that surfaces three weeks later in a report nobody can explain.

Then there is noise. Timestamps, session identifiers, CSRF tokens, random values and generated ids differ on every run, so a naive diff reports that everything changed. You need a normalisation step before comparison: strip or freeze the known-variable fields, sort collections where order is genuinely not meaningful, round floating point to a sensible precision. Getting this right takes a day, and it is what turns the technique from interesting into usable.

Where the requests come from

Do not write the request list by hand. You will write the requests you remember, which are the ones you already understand.

Take them from production access logs. A week of real traffic gives you the paths that matter, with their real parameters, in their real proportions. Deduplicate by route and keep a spread of parameter shapes, because the interesting failures cluster around unusual inputs.

Then prioritise deliberately. Anything touching money first. Then authentication and permissions, because the comparison change above lands there. Then whatever appears most in the logs.

And do not stop at HTTP. Scheduled jobs and queue workers run the same code with none of the observation, so they need the same treatment. In my experience the cron jobs are where the surprises live, because nobody has looked at them since they were written.

Reading the differences

Every difference falls into one of three buckets.

Genuine bugs the upgrade exposed. Often these were bugs all along, hidden by a coercion or a suppressed warning. Fix them properly.

Behaviour changes between versions. String handling, type juggling, date functions, sorting. These need a decision rather than a fix: match the old behaviour deliberately, or adopt the new one and tell whoever owns the business rule.

Harmless variation. Ordering with no meaning, whitespace, a header. Add these to the normalisation rules and stop seeing them.

Work the list until it is empty. Empty is a real and reachable state, which is what makes this method calming rather than open-ended. You are not asking whether the application is correct. You are asking whether it changed, and that question has an answer.

The by-product is the thing you were missing

Every difference you investigate and resolve is a documented case: a request, a starting state, an expected result, and a reason. That is a test.

By the end of an upgrade done this way, you have a regression suite covering precisely the paths real users exercise and the areas that proved fragile. The application that arrived with no tests leaves with tests, built from evidence rather than guesswork. That is worth more than the version bump.

Budget for the discovery

The version change is an afternoon. The environments are a few days. The comparison and the fixing is the project, and it scales with the size of the application and the years of accumulated behaviour, not with the size of the version jump.

Give that estimate early, because a plan built on the afternoon figure fails publicly in week three.

We used this approach across a two year engagement moving dozens of applications from PHP 5.6 to 8, running legacy and target environments in parallel against the same requests. The differential comparison is what made a portfolio of undocumented applications tractable, and it is the first thing I set up on any upgrade now.

If you are staring at an upgrade with no tests, do not start by reading the code. Start by building something that can tell you when the answer changed.

Want to talk through your own project?

Book a call. You'll talk to the person who'd actually architect it, not an account manager.