How to Upgrade PHP 7.4 to PHP 8.3 Without Breaking Production
Security support for PHP 7.4 ended in November 2022. Every CVE published since then is unpatched on that runtime, and a surprising number of production applications still run it anyway. The usual reason is fear: the app makes money, the last person who understood the deploy process left two years ago, and nobody wants to be the one who broke checkout over a version bump.
That fear is manageable. A PHP 7.4 to 8.3 upgrade is a well-trodden path with mature tooling, a short list of known traps, and a test strategy that catches almost everything before a user sees it. This post walks through the process in the order we run it on client projects: measure the gap, automate the mechanical work, hand-review the behavioral changes, prove it in CI, then flip production with a rollback ready.
Why 8.3, and Why Now
The security argument is the obvious one, but the dependency argument usually bites first. The Composer ecosystem has moved on: new releases of the libraries you rely on require PHP 8.1 or later, so a 7.4 app is pinned to old library versions that carry their own unpatched vulnerabilities. The longer you wait, the wider that gap gets, and the more of the upgrade you end up doing all at once.
Performance is the pleasant side effect. Between the engine work in 8.0 and the steady optimization since, real applications typically see measurably better throughput and lower memory use after the jump. The exact number depends on your workload, so benchmark your own app rather than trusting someone else's chart.
Why 8.3 rather than 8.4 or 8.5? Ecosystem coverage. By now every maintained library, PECL extension, and distro package supports 8.3, which is also the floor for current Symfony tooling and the version most LTS distributions ship. It receives security fixes until December 2027, which buys you time to make the next jump a routine minor upgrade instead of another rescue project. Everything in this guide applies unchanged if you decide to land on 8.4 instead.
Build the Compatibility Report Before You Touch Code
Do not start by changing the version and seeing what explodes. Start with three commands that tell you how big the job is.
First, ask Composer which dependencies block the target version:
composer why-not php 8.3
Every line of output is a package that needs an update, a replacement, or a fork. This list, more than your own code, usually decides the timeline.
Second, run PHP_CodeSniffer with the PHPCompatibility ruleset against your source:
composer require --dev phpcompatibility/php-compatibility
vendor/bin/phpcs -p src/ --standard=PHPCompatibility --runtime-set testVersion 8.3
This is a static scan for syntax and functions that changed between your current version and 8.3: removed functions like create_function and each, curly brace array access, deprecated dynamic properties, and so on. It produces false positives on dynamic code, but it is the fastest way to get a count of real incompatibilities per directory.
Third, run Rector in dry-run mode with the PHP rule sets from 7.4 up to 8.3 and read the diff stat. You are not applying anything yet. You are sizing the mechanical portion of the work, and the ratio between the Rector diff and the PHPCompatibility findings tells you how much will be automated versus hand-fixed.
While you are at it, inventory your extensions. composer.json declares some in ext-* requirements, but check php -m on the production box for the rest. Compiled extensions like redis, imagick, or amqp need builds for 8.3, and a missing one discovered on deploy day is an avoidable emergency.
Let Rector Do the Mechanical Work
Rector applies the version rule sets as codemods: typed property syntax, str_contains instead of strpos comparisons, removed function replacements, nullsafe operators where the old null checks were unambiguous. On most codebases it correctly handles the bulk of the changes, and reviewing its output per directory keeps the pull requests small enough to actually read. We covered the workflow in detail in Rector for legacy PHP, including the rule set ordering that avoids rules fighting each other.
Resist the urge to modernize style in the same pass. Constructor promotion, enums, readonly properties, and the rest of the PHP 8.3 feature set are worth adopting, but they belong in a follow-up. An upgrade diff that only contains compatibility changes is one your reviewers can trust.
The Breaking Changes Rector Cannot Fix
The dangerous changes in the 7.4 to 8.x jump are behavioral. The syntax stays valid, the code runs, and the result is different. No codemod can fix these because the tool cannot know what your code meant. Five come up on nearly every project.
String to number comparisons got saner in 8.0. On 7.4, 0 == "email" is true because the string is cast to a number. On 8.0 it is false. Code that loosely compares user input, status fields, or CSV values against zero can silently take the other branch. Grep for == against numeric literals in validation and import code, and read each hit.
Internal functions now throw instead of returning null. On 7.4, passing garbage to many built-in functions produced a warning and a null or false return that your code may have quietly relied on. On 8.x the same call throws a TypeError or ValueError. Defensive code written as if (!$result) never runs because the exception fires first. Your error tracker, not the type checker, is how you find these, which is one reason the CI phase below matters.
Named arguments made parameter names part of the API in 8.0. If you adopt them, a dependency renaming a parameter becomes a breaking change for you. The subtle variant from the changelog: call_user_func_array with an associative array now treats string keys as named arguments instead of ignoring them. Dynamic dispatch code that builds argument arrays from user data or configuration needs a careful look.
Match expressions are exhaustive. Rector can convert a switch to match, but a switch without a default used to fall through silently, while a match throws UnhandledMatchError for an unmatched value. That is usually an improvement, and it is also a new production exception in a code path that never threw before. Review every conversion where the original had no default branch.
Implicit float to int conversion that loses precision is deprecated since 8.1. Array offsets and string offsets computed from division are the common source. Nothing breaks yet, but the deprecation notices will flood your logs on day one, so fix the top offenders before the flip rather than after.
Dynamic properties join the list in 8.2: assigning an undeclared property on a class raises a deprecation. Legacy code that stashes ad hoc state on entities does this constantly. Declare the properties, or put #[AllowDynamicProperties] on the class as a documented stopgap.
A Test Strategy That Finds Failures Before Users Do
The safe pattern is boring: run your existing test suite against both versions in CI, and do not touch production until the 8.3 job has been green for a while.
Add a PHP 8.3 job to your CI matrix next to the 7.4 one. Production stays on 7.4, deploys continue as normal, and the new job is simply allowed to fail at first. Its failure list is your work queue. Fix forward on the main branch, keeping every change compatible with both versions, which the 7.4 job enforces automatically. Run the 8.3 job with error_reporting(E_ALL) and deprecations converted to failures, because the deprecations are your early warning for the behavioral changes above.
Once both jobs are green, put staging on 8.3 while production stays on 7.4, and run it that way for a week or two of real QA traffic. The bugs that surface there are the ones your suite does not cover, which tells you something useful about the suite as well.
If your coverage is thin, write characterization tests for the critical flows first: sign-up, checkout, the billing webhook, whatever pays the bills. You are pinning current behavior, including current bugs, so the upgrade diff has something to be checked against. This is the single biggest schedule factor, and it is where an outside code review pays for itself, because knowing which flows are risky is most of the work.
Symfony and Doctrine: The Compatibility Bridge
Never upgrade PHP and the framework in the same release. Symfony 5.4 LTS is the standard bridge, because it runs on everything from PHP 7.2.5 to current 8.x. The sequence that works:
| Step | Symfony | PHP |
|---|---|---|
| Start | 5.4 | 7.4 |
| PHP upgrade | 5.4 | 8.3 |
| Framework upgrade | 6.4 LTS (needs PHP 8.1+) | 8.3 |
| Optional next step | 7.x (needs PHP 8.2+) | 8.3 |
If you are on Symfony 4.4 or older, get to 5.4 on PHP 7.4 first. Each step has one variable, so each failure has one suspect.
Doctrine is mostly a dependency update: recent ORM 2.x releases run fine on 8.3, and the same composer why-not output tells you exactly which of your Doctrine packages need bumps. If you are still on DBAL 2, fold the DBAL 3 migration into the dependency pass, since DBAL 2 is end of life. Leave the annotations to attributes conversion for the modernization follow-up; doctrine/annotations still works on 8.3 and Rector converts it later in one shot.
Shipping It With Kamal or Coolify
If you deploy containers, the runtime change is a base image tag: php:8.3-fpm or the matching FrankenPHP image instead of the 7.4 one. Rebuild your PECL extensions in the new image, and ship your php.ini inside the image so staging and production cannot drift apart. Leave opcache on and leave JIT at its default off setting; for typical web workloads it changes little and adds a variable you do not want during an upgrade.
With Kamal, the upgrade is an image swap with a rolling restart, and the previous image stays available, so kamal rollback is your escape hatch and takes seconds. With Coolify, point the resource at the new Dockerfile or set the new PHP version in the build pack, deploy to a staging resource first, and make sure the health check hits a route that exercises the database rather than a static 200. Either way, the flip itself should be an anticlimax, because everything interesting happened in CI weeks earlier.
A Realistic Timeline for a PHP 7.4 to 8.3 Upgrade
From the projects we have run, three brackets cover most cases. A small codebase under 50,000 lines with a reliable test suite is one to two weeks alongside normal feature work, and most of that is dependency updates. A mid-size application between 50,000 and 200,000 lines with mixed coverage lands at three to six weeks, with the CI matrix phase dominating. A large legacy system beyond that with little test coverage is a two to four month effort, and the honest breakdown is that most of the time goes into building the safety net, and only a fraction into the upgrade itself.
Poor test coverage, not codebase size, is the multiplier. If the estimate scares you, that is the codebase telling you what it actually needs.
If your application is still on 7.4 and nobody on the team wants to own the jump, this is exactly the kind of project we take on as part of legacy code optimization: assessment first, a fixed plan with the brackets above made specific to your code, then the upgrade itself with your team in the loop. Write a few lines about your stack to hello@wolf-tech.io, or have a look around at wolf-tech.io. An assessment costs you an afternoon and replaces the fear with a number.

