PHP Modernization

Your PHP 7.4 Estate Is Past End of Life. The Upgrade Is Still a Sequencing Problem

Unsupported PHP in production is rarely a knowledge problem. It is a coupling problem: abandoned packages, no tests on the money path, and a release process nobody trusts.

PHP 7.4 reached end of life on 28 November 2022. PHP 8.0 followed in November 2023. PHP 8.1’s security window closed at the end of 2025. None of that is news to the teams still running those runtimes. They have the CVE mail, the pentest finding, the recruiter who bounced off the version in the job post. PHP 7.4 still in production is not a knowledge problem. It is a coupling problem: Composer packages nobody owns, extensions compiled against an old ABI, tests that do not cover the money path, and a release process that turns every upgrade into an incident rehearsal.

“It works” is the real policy. The policy is not written down. It shows up as a frozen composer.lock, a platform.php pin, a Docker image that has not been rebuilt in two years, and a Friday superstition about deploys. Security wants 8.4. Product wants the next billing feature. Engineering wants not to be the person who broke invoicing. Those three wants do not resolve themselves by reading the migration guide.

This article is about unsticking that. Not about whether PHP is “dead.” It is not. Not about whether you should rewrite the product in Go because the runtime is old. That is usually the wrong answer to an old runtime. The work is inventory, characterisation, and a sequenced bump that you can reverse.

Why production stays on 7.4 and 8.0 after end of life

The honest reasons are boring. That is why they survive steering committees.

The money path has no tests you trust. Checkout, invoicing, tax, partner settlement, the export finance runs at month-end. There may be PHPUnit files. They cover a service class with mocks. They do not freeze the HTTP-and-SQL behaviour of the path that prints money. Without that freeze, an upgrade is a bet. People refuse bets on Friday, then on Thursday, then indefinitely.

Composer is a fossil record. A package that last tagged in 2021 declares php: ^7.2|^8.0. It installs on 7.4. It explodes on 8.1 because it uses each(), or a removed parameter order, or an extension that vanished. Nobody in the company wrote it. The author archived the repo. Forking it is a new product. Replacing it is a project. So the lockfile stays.

Extensions pin the interpreter. ext-redis is fine. ext-imap is annoying. ionCube, custom ext-* built against 7.4 headers, an old sqlsrv build, a PDF library that ships a binary — those are not composer update. They are an ops ticket with a vendor who may not exist. Until that ticket has an owner, the PHP version is not a Composer decision.

The release path is already scary. If a normal feature deploy needs a checklist, a hero, and a war room, a runtime bump is unthinkable. The upgrade is then used as a reason not to touch deploys, and the deploys are used as a reason not to upgrade. I have watched that loop last years. It is not a PHP problem. It is a pipeline problem wearing a version number.

Compliance and hiring lag the runtime, they do not drive it. Auditors will write “unsupported software” in a report. Candidates will ask. Neither group will sit with you through a failed composer update on a Monday. They create pressure. They do not create a sequence. Pressure without a sequence produces a rewrite proposal, which is how you pay legacy PHP modernization cost for a strategy you never chose.

PHP 8.2 is still in security support until 31 December 2026. PHP 8.3 and 8.4 are the floor I would actually plan toward in late 2026. Jumping 7.4 → 8.4 in one PR is how you mix five years of language changes with five years of framework changes and call the wreckage “the upgrade.” Sequence the interpreter. Then the framework. Then the abandoned library that was the real pin. Order matters.

Abandoned packages and ext pinning freeze the runtime

Start with the lockfile, not with a blog post about named arguments.

composer why-not php 8.3 is the first command, not the last. It will lie by omission. It tells you constraints. It does not tell you which package will fatally error on a removed function even though its constraint says ^8.0. Constraints are hopes. Runtime is evidence. You need both.

Classify every blocker:

  • Dead but small. A helper package, a thin wrapper, fifty lines of your own code would replace it. Replace it. Do not wait for a revival.
  • Dead but load-bearing. A payment SDK, a tax engine, an ERP client. You cannot rewrite it as a side task. You wrap it, vendor it, or buy a replacement with a supported PHP constraint. Wrapping means an interface you own and a test that the wrapper still speaks the protocol. Vendoring means you accepted maintenance. Say that out loud.
  • Alive but lagging. The package supports 8.2 and you are on 7.4 because something else pins you. That is not this package’s fault. Do not put it on the same sticky note as the abandoned ones.
  • Extensions. List them from the production image, not from composer.json. composer.json mentions ext-intl. Production also has a PECL module someone installed by hand in 2019. That module is the pin.

A useful artefact is a table, one row per blocker: package or extension, last release date, PHP constraint, whether it fatals on 8.1/8.2/8.3 in a scratch container, owner, replacement option, effort in days not story points. If the owner cell is empty, the upgrade is not blocked by PHP. It is blocked by a missing name.

Forks have a cost that teams under-count. A fork that you never run CI against is a time bomb with a different namespace. If you fork, you own tests, a release process, and a plan to get off the fork. Otherwise you have invented a private abandoned package.

Polyfills (symfony/polyfill-*, rector rules for small syntax) help on the edges. They do not resurrect each(). They do not fix a C extension. Use them to keep application code moving while you deal with the real pins. Do not use them as a strategy.

Inventory the blockers before you pick a target version

Picking “PHP 8.4” in a slide is not an inventory. An inventory is evidence from this system.

Runtime and image. What does production actually run? php -v in the container, not the README. Same for CLI workers and cron. I have seen FPM on 7.4 and a worker image on 8.0 because someone “started the upgrade.” That is two estates. Treat them as two.

Composer platform config. config.platform.php lying about 7.4 so local installs work is a smell. It means the lockfile is not the production interpreter. Fix the lie or you will test the wrong graph.

Framework major. Symfony 4 / Laravel 8 on PHP 7.4 is a different project from a custom framework. The framework bump may be blocked on the same packages. Do not start both in one branch. The upgrade versus refactor versus rewrite choice is the decision before the git work. If you cannot say which of those you are doing, you will do a rewrite and call it chore: php 8.

Tests and the money path. Which paths mint, move, or report money? Name them. If there is no automated check that they still behave, the first upgrade task is not PHP. It is characterisation.

Deploy and rollback. Can you roll back a PHP image without a schema fight? If no, the upgrade branch is not your first move. See the release-path problem for what it is.

Performance baseline. If the app is already slow, an upgrade will be blamed for whatever you did not measure before anyone proposed a rewrite. Capture p95, error rate, and queue age on the current runtime. You need a before. The after will be argued in a ticket.

Target version is a function of that list. If three abandoned packages only work through 8.1, you do not get to pick 8.4 this quarter. You pick 8.1 as a landing, or you schedule the replacements first. A landing on 8.1 in 2026 is still EOL. It can be a waypoint. It cannot be the destination you sell to security. Write the waypoint down so nobody thinks you finished.

Characterisation tests beat a leap of faith

Characterisation tests record what the system does today, including the ugly parts. They are not a design document. They are a ratchet. You run them on 7.4. You run them on the next image. When they fail, you know the upgrade changed behaviour. When they pass, you have evidence, not hope.

What to freeze:

  • HTTP money paths. Checkout, refund, invoice PDF, tax calculation, the partner webhook that marks a bill paid. Drive them through the real stack in staging: web server, app, database. Capture status codes, response bodies where they are stable, and side effects in tables you own.
  • CLI and workers. The nightly settlement, the import, the report. Same idea. Input fixture, output rows, outbound HTTP if you can sandbox it.
  • Golden files where the format is the product. CSV/XML exports that a government portal or an accountant’s tool consumes. Byte-stable or semantically stable — decide which, and do not mix them.

What not to do: rewrite the internals into “proper” unit tests as a precondition. That is a refactor programme hiding inside an upgrade. You will never start. Mocking the ORM and asserting that a method was called does not tell you that PHP 8’s string-to-number changes altered a total.

A scratch container is cheap evidence. Copy the production image, bump PHP, run composer update against a branch, run the characterisation suite. Do this before you argue about Rector. Rector is a tool for code you will keep. It is not a substitute for knowing which packages fatal.

If you cannot run the suite in CI on two PHP versions, you do not have an upgrade pipeline. You have a laptop. Laptops do not ship.

Upgrade in slices, not as a weekend event

The slice is a PHP minor or major that you can build, test, and roll back. It is not “the whole modernisation.”

Practical sequence I actually use:

  1. Characterisation and baseline metrics. No code change required for the first week except tests and dashboards.
  2. Delete or replace the small dead packages. Shrink the constraint graph.
  3. Decide the extension story. New builds, replacements, or an honest “this pin remains.”
  4. Land the next interpreter that the remaining graph allows. Often 7.4 → 8.0 or 8.1, even if 8.0 is also EOL, because the language jump is the painful one and you want it isolated. Then 8.1 → 8.2 → 8.3/8.4 as packages allow.
  5. Framework bump after the interpreter is boring. Mixing Symfony 4 → 6 with PHP 7.4 → 8.3 is how you create a branch that cannot merge.
  6. Only then talk about extraction or Go. A new language does not remove an abandoned bundle. It adds a second runtime to an estate you already cannot patch.

Each interpreter landing gets its own production bake. Hold it through a billing cycle if you have one. The bug you care about shows up in the monthly invoice, not in the hour after deploy.

Rollback is an image pin and a config flag, not a git revert mixed with migrations. If the upgrade needs a schema change, split it. Schema first, compatible with old PHP. Then the new PHP. Then any cleanup the old PHP cannot run. Dual-running compatibility is tedious. It is also how you avoid a Saturday.

Rector and PHP-CS-Fixer are fine for mechanical syntax. create_function, ${} string interpolation, the last each() in your code — yes. They will not read the abandoned SDK. Do not measure progress in Rector rules applied. Measure progress in blockers retired and characterisation still green.

When a rewrite is the wrong answer to an old runtime

“We have to rewrite because we are on PHP 7.4” is almost always false. You have to leave PHP 7.4. Leaving it is an upgrade. A rewrite is a product freeze with extra hiring.

Go is a legitimate tool for a specific workload: long-lived concurrency, heavy fan-out, workers whose memory is the bill. It is not a PHP 7.4 escape hatch. If you extract one service and leave the monolith on 7.4, you now have an unsupported estate and a distributed system. That is worse. Patch the estate. Extract later if the profiler says so.

A rewrite also duplicates the characterisation problem. You still do not have a specification. You will rediscover tax rules in production, in a language the current team is learning, while the old system keeps changing. I will not help you do that as a response to a version number.

The PHP modernization audit exists to separate “unsupported runtime” from “wrong architecture” from “scary deploys.” Those are three programmes. Budgets that mix them produce neither a patchable PHP nor a working Go service.

Unstick the upgrade with a written sequence

Write a one-pager that security, product, and engineering can all read: current versions, blocker table, characterisation status, target waypoint, destination version, what will not change, rollback. If the one-pager cannot say “we are not rewriting,” it will be read as a rewrite. Say it.

Do the small replacements. Build the tests on the money path. Bump the interpreter in slices that bake in production. Keep the product calendar moving. That is the whole method. It is housework. Housework is what keeps an unsupported runtime from becoming a five-year architecture programme.

If you want that sequence written against your lockfile and your actual money path, the commercial form is a Migration Readiness Audit at €1,200: blockers, tests, deploy risk, and permission to conclude “upgrade only.” PHP modernisation implementation is €402/day when the document says the work is in PHP. PHP→Go work at €402/day stays optional and later. The audit is allowed to tell you not to open a new language.