Why does an end-of-life PHP version matter?
Because nobody fixes it any more. Once a PHP branch reaches end of life, the PHP project ships no further releases for it, security fixes included. PHP 8.1 got its last release, 8.1.34, and reached end of life on 31 December 2025. PHP 8.0 ended on 26 November 2023, 7.4 on 28 November 2022. PHP 8.2 is next: security support ends on 31 December 2026, eleven weeks after this article went up. A vulnerability found in January gets patched in 8.3 and later, and an 8.2 server keeps the hole.
At least the schedule is predictable now. Every branch gets two years of active support plus two of security-only fixes, and every end date falls on 31 December. PHP 8.6 is already at RC3 (8 October 2026).
The framework often runs out first. Laravel gives each major version 18 months of bug fixes and two years of security fixes. Laravel 11 lost security support on 12 March 2026; Laravel 12 stopped getting bug fixes on 13 August 2026 and gets security fixes until 24 February 2027. Laravel 13 (17 March 2026) requires PHP 8.3, so a team parked on PHP 8.2 is also parked on a framework branch with a few months left.
Plenty of teams sit exactly there. In Packagist install data for 1 July 2026, PHP 7.4 still had 5% of installs, 8.1 another 5% and 8.0 1%. Composer will not push anyone off: of the 1,000 most popular packages, only 352 require a PHP version that still receives security updates.
The push arrives from outside, as a line in a customer's security questionnaire or a pen test report.
Upgrade, strangle or rewrite: how do we decide?
We ask five questions in a fixed order, and the answers usually pick the option for us. The default is to upgrade first, strangle the parts that cannot be upgraded, and rewrite only systems that are small or truly dead. Some tests, a maintained framework and a decent schema add up to an upgrade, sometimes with one module strangled because a dead extension pins it to an old PHP.
- Are there tests, and do they run? If not, characterisation tests on the paths the business bills on come first, whatever we decide later.
- Is the framework maintained, with a documented upgrade path? Laravel and Symfony publish an upgrade guide for every major release. A home-grown framework from 2011 has none.
- Is the data model sound? Messy code on a good schema is an upgrade job. A bad schema makes every option expensive, and a rewrite does not escape it, because the data comes along.
- Can modules be cut at an HTTP or queue boundary? If reporting or a supplier integration can be routed separately, strangling is cheap.
- Does anyone understand the business rules? If they live only in the code, a rewrite has to dig them out of that same code.
Four options side by side
Read the "Main risk" column first. Each option fails in a different place, and the failure you can afford usually decides more than the benefits do.
| Option | Best when | Main risk | Business disruption | Time to first value | Our default when |
|---|---|---|---|---|---|
| Upgrade in place | Framework maintained, schema sound, some tests | Quiet behaviour changes between versions (null handling, removed functions) | Low: ships as normal releases | Early: every merged step is in production | Almost always the first move |
| Strangle (incremental replace) | A few modules pinned by dead code or extensions; clear HTTP or queue seams | Two systems and shared data living side by side for a long time | Low per step, plus a routing layer to run | Early for the first module, slow for the whole system | Parts the upgrade cannot reach |
| Full rewrite | Small system, dead framework, rules documented, or the product is changing | Undocumented rules rediscovered as bugs; nothing ships before cutover | High at cutover | Late: value arrives at cutover | Small systems or a changed product |
| Wrap and leave (freeze + isolate) | Retirement is planned and dated, little change expected | Still unpatched; isolation only narrows exposure | Minimal | None, it buys time | A retirement date already exists |
How do we climb several PHP versions safely?
One minor version at a time, with a test net first and tools doing the mechanical edits. We write characterisation tests for the paths the business earns on, get PHPStan running at a low level, let Rector apply version upgrades in small batches, and keep CI green on both the current and the target PHP version before production switches. Each step ships on its own.
Characterisation tests record what the code does today, wrong answers included. A rounding bug found on the way gets its own ticket; fixing it inside the upgrade makes the diff unreviewable.
Each version breaks something different, so we read the php.net migration guide for every step. PHP 5 era code still calls mysql_query(), and that extension was removed in 7.0. PHP 8.0 removed each() and create_function(). PHP 8.1 deprecated passing null to non-nullable parameters of built-in functions, which turns up all over code written for 7.x. PHP 8.4 deprecated implicitly nullable parameter types.
Rector does most of these edits, and its own documentation warns against the obvious move. Calling withPhpSets() on a project declared as ^7.4 applies the sets for 5.3 through 7.4 at once, "over 100 rules". The docs recommend withPhpLevel(), raised by one per merged pull request, after reaching PHPStan level 3–4 without a baseline. Current releases: Rector 2.7.0 (6 October 2026, runs on PHP 7.4+) and PHPStan 2.3.
<?php
// rector.php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/app', __DIR__ . '/tests'])
->withPhpLevel(1); // +1 per merged PR, then withPhpSets()
composer why-not php 8.3 lists every package that blocks the target version. Most blockers have a newer major or a fork. The few with neither are where the strangler conversation starts.
CI runs the suite on both versions until the switch:
strategy:
matrix:
php: ['8.2', '8.3']
steps:
- uses: actions/checkout@v7
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
- run: composer install --no-progress
- run: vendor/bin/phpunit
Production moves one server or worker pool first. Whether PHP should stay in the stack at all is a separate question, covered in Python, PHP or Node.js for your back end.
When is the strangler approach right, and how does it work in PHP?
Strangle a module when it cannot be upgraded at a sensible cost but can be cut off at a boundary: its own URLs, its own queue, ideally its own tables. A façade (nginx, a load balancer, or the legacy front controller itself) sends requests for that capability to the new code and everything else to the old app, so users still see one system. Microsoft's pattern guide lists where it does not fit: requests you cannot intercept, no access to the legacy source, a small system that is easier to replace whole, or a need to switch the old one off quickly.
In a PHP estate the façade is often a few lines of nginx:
location /reports/ {
proxy_pass http://reports-v2:8080;
}
location / {
try_files $uri /index.php?$query_string;
}
Sessions are the first snag: the new service reads the legacy session or trusts a token set at the façade. Decide before the first route moves.
The database is where strangler projects go wrong, and most write-ups skip it. Two applications writing one table with different validation rules produce rows neither expects, so our rule is one writer per table, written down. Microsoft's guide describes the sequence we follow. The new service first works on its tables inside the old database; an ETL load plus change data capture then fills a separate domain database; consistency is checked; the new database becomes the system of record; legacy tables go last, because after that rollback gets expensive. We have run those CDC mechanics on our own project, moving 12 TB of PostgreSQL with under four minutes of downtime (the write-up).
Where new code must call old code, an anti-corruption layer keeps legacy field names and status codes at the boundary. It costs latency and one more component to run.
When is a full rewrite actually justified?
Less often than it feels from inside a messy codebase. We consider a rewrite when the system is small enough to replace in one go, when its framework is dead with no migration path, when the business rules are documented outside the code, or when the product itself is changing and the old behaviour need not survive. Microsoft's strangler guide makes the same exception for small systems.
The costs, in the order they usually bite:
- Nothing ships until cutover, while the old system still needs fixes.
- Every rule that lived only in the code comes back as a bug report after launch.
- The data migration is the same work as in any other option, squeezed into one weekend.
The one rewrite we have described on this blog moved a mobile app from React Native to native Swift: 100 Hz Bluetooth telemetry saturated the JavaScript thread, with lag over 1,500 ms. That was a runtime ceiling. A PHP system needs a reason of the same weight.
What should the first pilot look like?
One bounded module, acceptance criteria written before any code, old and new running side by side, then a decision on scope. Good candidates are a report, a single integration, an admin screen or document intake: clear inputs and outputs, and a wrong result shows up before it costs anyone money.
The new module runs in shadow mode first. It gets the same inputs, the old one still answers, and a script diffs the results on real traffic against criteria agreed with the process owner (which totals must match, which differences are allowed). The pilot leaves a module in production and a list of what the full job contains: dependency blockers, missing tests, rules nobody had written down. That list is what makes a scope estimate honest.
A pilot can run as a fixed-scope project or as team extension next to your developers.
Keeping the business running during the work
Ship small, keep the old path one switch away, and watch production before changing it. The feature freeze covers only the module being moved; everything else keeps shipping. Error tracking and slow-request logging go in before the first change, so there is a baseline. Rollback steps are written and rehearsed before cutover, and where outputs can be compared, old and new run in parallel for a while. Before a cutover we run the same checks as before a sale or launch (our peak-load checklist).
Where does AI help in a legacy migration, and where not?
AI is good at reading and testing legacy code and much weaker at writing the replacement unattended. We use models to explain undocumented modules, draft characterisation tests and pull business rules out of code into plain-language lists that a domain owner then corrects. An engineer reviews every output, and nothing generated merges without tests.
Recent research supports that caution. In LegacyTranslate (March 2026), built during a migration of about 2.5 million lines of PL/SQL to Java at a financial institution, the first-pass translation agent produced compilable code 45.6% of the time and passed tests 30.9% of the time. Grounding it in internal APIs and feeding compiler errors back added a further 8% and 3%.
The stronger pattern runs the other way: the agent generates tests and the old system is the judge. A July 2026 paper checked COBOL-to-Java migrations with deterministic parity tests and reached 91.90% branch coverage on an internal program. For PHP that oracle is already running on your servers. Agents get the same access rules as anyone else: no production credentials, every action logged (seven controls we never skip).
Running PHP 7.x, 8.0 or 8.1 in production?
Send us the composer.json, the PHP version on each server and the three modules that change most often. In a discovery call we tell you which of the four options we would start with and what the first pilot would be. More on our web development work, or write to us through the contact form.