Guides 9 min read

How to Upgrade PHP 7.4 to 8.3 Without Breaking Your Application

A step-by-step upgrade plan for PHP 7.4 to 8.3: find the breaking changes before you touch the code, fix them in batches and verify every release with your test suite.

A PHP 8 upgrade looks like a configuration change and behaves like a refactoring project. The runtime change is one line; the code that assumed the old behaviour is the actual work. The good news is that the set of things that break is finite, well documented and mechanically detectable, which means a migration can be planned instead of guessed at.

The short answer

Yes, you can move from PHP 7.4 straight to PHP 8.3, but do it in four steps: take an inventory of what the project uses, scan the source code against the target version, fix the findings in small reviewed batches, and verify each batch with your test suite. The version jump is rarely the problem. The problem is the code that depends on behaviour PHP 8 removed, and that code is scattered across files nobody remembers writing.

A scan of a mid-sized legacy codebase typically finds the same handful of categories, and the categories are what you plan around:

  • Removed function families: each(), create_function(), the mysql_* procedural API and the ereg() family.
  • Null passed to a non-nullable internal function parameter, which was a silent pass before PHP 8.1 and became a deprecation.
  • Required parameters that follow optional ones, which PHP 8.0 turned into a fatal error at compile time.
  • The removal of the automatic conversion of false to an empty string inside a string.
  • From PHP 8.2: the creation of dynamic properties on objects that do not declare them.

Step 1: take an inventory

Before touching any code, write down the facts that determine the shape of the migration:

  1. Where you are. Read the php requirement in composer.json. That constraint is the version your dependencies were resolved against and it is the most reliable single source of truth.
  2. Where you are going. Pick the target version your hosting provider supports, not the newest one available. PHP 8.2 and 8.3 are both mature; PHP 8.4 is a step further than most stacks have settled on.
  3. What you run. Note the web server, the extensions you rely on, the database driver and the PHP version your CI uses. A mismatch between your CI version and your production version is the classic source of a surprise after the deploy.
  4. How you test. Count the tests and run them now. A green suite before the upgrade is your only reference: afterwards, a failure tells you the upgrade broke something, not that the test was already broken.

Update the dependencies before you update the runtime. A large share of the "the framework is broken on PHP 8" problems are simply an out-of-date package that has a PHP 8 compatible release already.

Step 2: scan the code

Run a compatibility scan against the target version. The output should give you, for every finding, the file, the line, the PHP version it affects and the recommended change. If the tool only gives you a count, it is not much use: the count tells you that you have work, the locations tell you what to do.

Group the findings by rule rather than by file first. The rule tells you how many decisions you actually have to make. A finding that maps to a single mechanical replacement is a different task from a finding that needs a decision about behaviour.

php2shell does this in one pass: the rule set mirrors the Rector upgrade rule sets, so the findings map onto transformations that already exist in the PHP ecosystem rather than onto invented advice.

Step 3: fix in batches

Do not try to fix everything in one commit. Group the automatic changes by rule and land one group at a time, with the test suite running after each one. The order that usually costs the least effort:

  1. Dead removals. each() becomes a foreach, create_function() becomes a closure, ereg() becomes a PCRE pattern. Mechanical, and the test suite covers them well.
  2. Database access. The mysql_* family is gone. If the code still uses it, you have a PDO or MySQLi migration in front of you, and that is a project of its own.
  3. Null handling. Null passed to a non-nullable parameter has to become an explicit check, a default or a cast. This is where behaviour changes, so it deserves its own commit and its own review.
  4. Signature consistency. Required parameters that follow optional ones either get a nullable default or the list gets reordered. Reordering changes the public API, so it needs a careful look at every call site.
  5. Dynamic properties. Declare the property on the class, or add #[AllowDynamicProperties] where the class genuinely stores extra data. This one is a deprecation in 8.2 and 8.3, so it can wait for last.

Keep the diffs small. A reviewer can verify a diff that touches one rule across forty files. They cannot verify a diff that touches four rules across four hundred files.

Step 4: verify every release

The upgrade is done when the test suite is green, not when the scan is clean. Two things make the difference:

  • Run the tests on the new runtime before you deploy. Use the exact version, with the exact extensions. A Docker image pinned to php:8.3-cli and your production host are close enough for most purposes and very different in the details.
  • Parse the PHPUnit output instead of reading the wall of text. You want the pass rate and the list of failing test cases with their locations. That list is your work list for the next batch.

php2shell parses the PHPUnit output for you and turns it into that list. There is a dedicated article on the verification step if you want the details.

Should you go branch by branch?

It depends on the size of the codebase and the state of the test suite.

  • Small project, good tests: go straight to the target. The intermediate releases buy you nothing.
  • Large project or weak tests: stop at 8.0 first. PHP 8.0 is where the removed function families bite, and it is also where the change is largest. Once you are green on 8.0, moving to 8.1, 8.2 and 8.3 is usually a smaller step each time.

Note that 8.1, 8.2 and 8.3 are almost entirely deprecation work. They tell you what will break in PHP 9, which is why the right time to fix them is while you are already in the code.

The five mistakes that cost the most time

  1. Upgrading the runtime before the dependencies. You end up debugging a framework bug instead of a compatibility problem.
  2. Fixing everything in one commit. When the suite goes red you cannot tell which change caused it.
  3. Ignoring the deprecations because they are not errors yet. Every deprecation in 8.1 to 8.3 is a future error in PHP 9.
  4. Testing on a different PHP version than you deploy. The last two percent of a migration go wrong here.
  5. Skipping the scan because "we have run this app for ten years". The code that breaks is exactly the code that runs rarely, which is why it is still there.
Disclaimer. This article is guidance for planning a migration. The automatic fixes that php2shell applies are best-effort transformations. Always review the diff and run your test suite in a staging environment before you deploy. php2shell does not guarantee that an upgrade is free of runtime errors or business interruptions.

Frequently asked questions

Can I jump from PHP 7.4 straight to PHP 8.3?

You can, but it is much safer to go through the intermediate branches. PHP 8.0 removed whole function families, 8.1 tightened null handling, 8.2 deprecated dynamic properties and 8.3 deprecated a few more calls. Upgrading branch by branch keeps each set of changes small enough to review.

What breaks most often when moving to PHP 8?

Removed functions such as each(), create_function() and the mysql_* family, null passed to non-nullable internal function parameters, required parameters that follow optional ones, and the removal of the automatic conversion of false to an empty string.

How do I know which version my project needs?

Read the php requirement from composer.json. It is the version constraint your dependencies were resolved against and the most reliable single source of truth for the current runtime.

Where to go next