Web Development

Upgrading a Legacy PHP Application to PHP 8: A Practical Plan

The order we work in when an old PHP codebase has to move to a supported PHP 8 release: target version, inventory, static analysis, tests, the breaking changes that bite, and a staged rollout.

Upgrading a Legacy PHP Application to PHP 8: A Practical Plan

Most of the risk when you upgrade a legacy PHP application sits in what nobody remembers: a cron script that still calls mysql_query(), a PDF library copied into the repository a decade ago, a comparison that only works because PHP used to treat "abc" as zero. PHP 8 does not break tidy code. It stops tolerating habits that older versions let through.

The plan below is the order we work in on upgrade projects: decide the target, list what the application depends on, let tools find the breakage, put tests around the flows that matter, fix the code, then roll out in stages with a way back.

Choose the target version first

Every PHP branch gets two years of active support and then two years of security fixes only. The dates are on the PHP supported versions page. Pick a branch with most of that window still ahead. At the time of writing that means 8.4 or 8.5. PHP 8.2 is in its last months of security fixes, so an upgrade that lands there buys very little.

Two things can force a lower target for now: a dependency with no release for the newest branch, and a host that does not offer it yet.

If the application is still on PHP 5.x, plan two legs. The first leg, to 7.4, deals with what PHP 7 removed, the old mysql_* functions and the mcrypt extension among them. Once the code runs on 7.4, most of what PHP 8.0 removes still works but raises deprecation notices, so you get a list of what to fix while the application keeps running. PHP 7.4 no longer receives security fixes either, so treat it as a stage and never a place to stay.

List everything the application depends on

If the production code is not in version control, that comes first. Copy what is on the server into a Git repository, including the files someone edited there directly.

Then write the inventory:

  • The runtime. On the production server run php -v, php -m for loaded extensions and php --ini to find the configuration files. Note every setting that differs from the defaults.
  • Every entry point. Cron jobs, command-line scripts, webhook receivers and import scripts are where forgotten code lives, and cron often runs a different PHP binary than the web server.
  • Libraries. Read composer.json if it exists, and look for libraries that were copied in by hand: mailers, spreadsheet readers, PDF generators, payment SDKs. Write down the version of each.
  • Removed extensions. Search the code for mysql_, mcrypt_, ereg and the old split() function. These are gone, and each needs a real replacement.

With Composer in place, one command shows which packages refuse the target version:

composer why-not php 8.4

Let tools find the breakage before users do

Three tools cover most of it without running the application.

The PHP linter. php -l, run with the binary of the target version, reports code that no longer parses, such as curly-brace string offsets.

PHPStan reads the code without executing it and reports calls to functions that no longer exist, wrong argument counts and type mismatches. Start at level 0, set phpVersion in its configuration to the target, and record the existing findings as a baseline so that new problems stand out.

Rector rewrites old constructs for a target PHP version. Run it with --dry-run first, read the diff, apply one PHP version at a time and commit after each. The Rector project page documents the rule sets.

# syntax check every file with the target PHP binary
find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \; \
  | grep -v "^No syntax errors"

composer require --dev phpstan/phpstan rector/rector
vendor/bin/phpstan analyse src --level 0
vendor/bin/rector process src --dry-run

A minimal rector.php for the first step:

<?php

use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPhpSets(php80: true);

Put tests around the flows that earn money

Legacy code rarely has tests, and an upgrade is the wrong moment to chase coverage. What you need is a small set of checks that record what the application does today, so you can run them again on the new version and compare.

Pick the flows where a silent change would hurt: login and password reset, the main create and edit screens, anything that calculates money, tax, dates or rounding, imports and exports, and scheduled jobs. For each one, capture the result on the old PHP version (the HTTP response, the totals in a report, the rows of an export) and assert the same result on the new one. PHPUnit covers the logic you can call directly, and an HTTP client script covers the rest.

The most dangerous PHP 8 changes do not throw errors. A comparison takes the other branch, a total comes out different, and static analysis has nothing to say about it.

The breaking changes behind most of the work

Each PHP release has a migration guide in the appendices of the PHP manual. In legacy applications the same handful of entries account for most of the fixes.

ChangeSinceWhat it looks like in old code
mysql_* functions removed7.0Fatal error on the first query. Move to PDO or mysqli with prepared statements, not to a wrapper that imitates the old functions.
Numbers compared loosely with non-numeric strings8.00 == "abc" is now false. Status checks and switch statements take a different branch with no error at all.
Undefined variables and array keys raise warnings8.0Logs fill up. Code that relied on a missing key being quietly null needs ?? or isset().
Built-in functions throw TypeError or ValueError on bad arguments8.0A call that used to return null with a warning, such as count() on a non-array, now stops the request.
PDO and mysqli throw exceptions by default8.0 and 8.1Code that checks for a false return never reaches its own error handling.
Passing null to non-nullable parameters of built-in functions is deprecated8.1strlen(), trim() and htmlspecialchars() on nullable database columns. Thousands of notices, one pattern.
Dynamic properties are deprecated8.2Classes that assign $this->anything without declaring it. Declare the property.
Implicitly nullable parameter types are deprecated8.4function f(Foo $x = null) becomes ?Foo $x = null. Rector fixes this one for you.

Fix what throws first. Deprecations do not stop the code, so send them to a log file on staging with error_reporting = E_ALL, display_errors = Off and log_errors = On, then work through them by how often each appears. One trap: a custom set_error_handler() that converts every notice into an exception turns a harmless deprecation into a crash.

Upgrade the dependencies as their own step

Keep library updates in separate commits from your own code changes, so that when something breaks you can tell which side caused it. Set config.platform.php in composer.json to the target version, then update one major package at a time and run the checks after each.

Composer marks abandoned packages when you install, and composer audit lists the ones with known security advisories. Replace both kinds. For libraries that were copied in by hand, install the current release through Composer and adapt your calls to it.

Roll out in stages and keep the way back open

  1. Automate the checks. The linter, PHPStan and the tests run on the target version for every commit.
  2. Stage it. Run the application on the target version with a recent copy of production data, and trigger the weekly and monthly cron jobs by hand.
  3. Install the new PHP beside the old one. Two PHP-FPM versions run side by side as separate services. Which one serves the site is a single line in the web server configuration.
  4. Switch at a quiet hour. Change the web pool, the command-line binary and every cron entry together.
  5. Watch the logs. Read the error log daily for the first week and keep the old PHP-FPM service installed. Going back is then one configuration change, as long as the new code has not written data the old code cannot read.

When upgrading a legacy PHP application needs a team

A codebase that is in version control, has a developer who knows it and already runs on 7.4 is a reasonable in-house project. It turns into a job for a team that does this regularly when nobody who wrote the code is still around, when the application is on 5.x with mysql_* calls in every file, or when it handles payments and has no tests. That is the work behind our PHP migration and upgrade service, which starts with the inventory and a report of what the tools found.

Sometimes the audit shows that fixing the code in place costs almost as much as replacing parts of it. Then the decision is a different one, and rewrite, refactor or wrap walks through it. If you want the code restructured along the way, see legacy PHP modernization. After the upgrade, a PHP maintenance plan keeps the next version jump small.

Frequently asked questions

Can I go from PHP 5.6 straight to PHP 8?

You can, but it is harder to debug. Stopping at 7.4 first separates the PHP 7 removals from the PHP 8 ones and turns many future errors into deprecation notices you can read in a log.

How long does a PHP 8 upgrade take?

It depends on the size of the codebase, how far behind it is, how many abandoned libraries it carries and whether any tests exist. The inventory and a first run of the static analysis tools give a defensible estimate.

Do all deprecation warnings have to be fixed before going live?

No. Deprecated code still runs on the version that deprecates it and stops working only in a later major version. Fix errors and warnings before the switch, keep deprecations in a log, and clear them in batches afterwards.

Is the upgrade different for a WordPress site?

Yes. WordPress core handles current PHP versions, so the work is in plugins and the theme, and the order of steps is shorter. We cover it in upgrading PHP on a WordPress site.

Add WorldWin Coder to your preferred sources

Add worldwincoder.com as a preferred source on Google, or open this article in your AI assistant to use it as a source.

Swati Priyadrshni
· Founder & CEO at WorldWin Coder

I'm Swati Priyadarshani, Founder & CEO of LMS Advisor and Co-Founder of WorldWin Coder Pvt. Ltd. (est. 2019). Over the last 10+ years, I've helped enterprises build learning platforms that actually get used - not just deployed.

Keep reading

Have something to build, fix or move?

Tell us where you are. A developer reads every inquiry, and you get next steps and a free quote within one business day.