Migrating PHP 7.4 to 8.x is the most common migration in today's projects — most applications written between 2019 and 2022 run on 7.4. The good news: it is also the best-documented and most automated migration. The bad news: four generations of changes accumulated between 7.4 and 8.4, each with its own list of pitfalls. This article breaks the transition down version by version — what exactly breaks, how to fix it and in what order to proceed.
Key takeaway: The order is fixed: 7.4 → 8.0 → 8.1 → 8.2 → 8.3 → 8.4. You do not have to deploy every step to production, but you must pass through each one at the code and test level. Rector walks you through all the transitions automatically (
withPhpSets(php84: true)), and the manual fixes focus on behavior changes, not syntax.
Why go step by step instead of jumping
Technically it is possible to rewrite code from 7.4 straight to 8.4 — Rector has a cumulative set. In practice a four-version jump has two drawbacks.
-
First: error diagnosis. When tests break after a mass change, you do not know which behavior is responsible. With version-by-version steps every commit corresponds to one transition — you find the bug in minutes, not days.
-
Second: dependencies. Frameworks and libraries have their own version requirements. Laravel 8 supports PHP 8.0, Laravel 9 — 8.0/8.1, Laravel 10 — 8.1/8.2, Laravel 11 — 8.2/8.3. Going step by step, you upgrade the framework alongside PHP, version by version — like stairs. A jump at once means upgrading the framework across four versions in a single step, which is a separate, large project.
In small projects (a dozen thousand lines, framework already supporting 8.4) a direct jump makes sense — Rector and tests are enough. In medium and large ones — steps.
7.4 → 8.0 — the biggest batch of changes
This is a generational transition: PHP 8.0 changed the error model, the type system and removed constructs from 5.x/7.0. The most important changes from the point of view of a typical project.
Exceptions instead of warnings. Many functions that returned false and logged a warning in 7.4 throw exceptions in 8.0 (ValueError, TypeError). This covers strpos() with invalid arguments, arithmetic functions, json_decode() with depth issues. Code that "worked despite warnings" stops working.
// 7.4: returns false + warning
$pos = strpos('abc', '');
// 8.0: throws ValueError — validate the arguments
if ($needle === '') {
// handle the edge case
}
Stricter string-to-number comparisons. 0 == "abc" was true in 7.4 and is false in 8.0. This change silently breaks condition logic in old code — tests on decision paths (permissions, carts, discounts) are mandatory here.
Removed functions and constructs. each() (deprecated for years), create_function(), money_format(), the mysql_* extension (removed back in 7.0), the old preg_replace /e modifier. Rector replaces most of them automatically.
Changes in sorting and array functions. usort() and the sorting family — since 8.0, sorts are stable, which changes the order of equal elements. If code relied on instability (and some old code did), results will change.
Constructor promotion, attributes, named arguments — new possibilities that Rector partially introduces automatically (e.g. promotion), but you do not have to use them. A migration is not a refactoring to a new style.
8.0 → 8.1 — null deprecations and new types
A gentler transition, but with one change that touches nearly every project.
Deprecation of null in non-nullable parameters. Passing null to internal functions expecting a string/int (e.g. strlen(null), htmlspecialchars(null)) is deprecated since 8.1. The code keeps working but clutters the logs — and in future versions it will be an error.
// 7.4/8.0: works without a warning
htmlspecialchars($value); // $value may be null
// 8.1+: deprecation — fix it explicitly
htmlspecialchars($value ?? '');
New features worth using along the way: enums (enum Status: string), readonly properties, never as a return type, fibers (for async libraries), array_is_list().
Behavior changes: $GLOBALS works differently (full write access), some warnings about optional parameters before required ones became deprecations. Rector has rules for most cases.
8.1 → 8.2 — dynamic properties and readonly classes
Another "deprecation-focused" transition — code keeps working, but the logs fill up.
Deprecation of dynamic properties. Creating properties on the fly ($obj->newField = 1 on an object that did not declare the field) is deprecated since 8.2. This affects old ORMs, DTO objects and code that "attached" fields to models. Solutions: declare the fields, use #[AllowDynamicProperties] as a buffer, or refactor to arrays/types.
class UserDto
{
// 8.2+ requires an explicit declaration or the attribute
public string $name;
public ?string $email = null;
}
Deprecation of part of string interpolation. Constructs like "${var}" (other than {$var}) are deprecated — a simple syntax swap Rector does automatically.
New features: readonly classes, DNF types ((A&B)|C), removal of some dynamic properties from the standard library. Full 8.2 support ships in Laravel 10/11 and Symfony 6.2+.
8.2 → 8.3 and 8.3 → 8.4 — cosmetics and wrap-up
The last two steps are the lightest — for most projects that is days of work, not weeks.
8.3: typed class constants (code overriding constants with a changed type gets an error — mostly libraries), get_class() without an argument deprecated (swap to static::class), minor Random\Randomizer changes, the new json_validate() function.
8.4: implicitly nullable parameters deprecated (string $x = null → ?string $x = null), property hooks (getters/setters on properties), asymmetric visibility, new array functions (array_find, array_any, array_all).
After reaching 8.4 the project is on a version with full support until the end of 2028 — and ready for 8.5 (another cosmetic transition when needed).
Summary table — what to check at every step
| Transition | Main risk | Typical fix | Time for a medium project |
|---|---|---|---|
| 7.4 → 8.0 | exceptions instead of warnings, string comparisons | argument validation, condition fixes | 1–3 weeks |
| 8.0 → 8.1 | null deprecations | ?? '' and explicit nullable types |
2–5 days |
| 8.1 → 8.2 | dynamic properties | field declarations / attribute | 3–7 days |
| 8.2 → 8.3 | typed constants (libraries) | dependency updates | 1–3 days |
| 8.3 → 8.4 | implicitly nullable parameters | Rector + signature review | 1–2 days |
A practical 7.4 → 8.4 plan
The order of work that works in projects.
- Audit against 8.4 — PHPCompatibility with
testVersion 8.4-and PHPStan show the full blocker list for all transitions at once. You then order the list by steps. - Critical-path tests — do not start without them. 15–20 end-to-end tests are enough to begin.
- Rector in steps — first
withPhpSets(php80: true), commit, tests. Thenphp81,php82,php83,php84. Each step is a separate commit and a separate test run. - Framework in parallel — upgrade Laravel/Symfony one version per PHP step (the framework-to-PHP version map is in the framework documentation).
- Staging after every two steps — deploy to staging and walk the manual tests; update production when the whole chain is green.
- Deprecations to zero — after each step, run the application and clear the warnings. Leftover 8.1/8.2 deprecations are the most common cause of surprises at later steps.
The technical workflow with Rector — configuration, modes, CI — is in the article on the Rector PHP upgrade guide. The full process with audit, staging and deployment is described in the PHP migration guide. If you are starting from an even older version (5.6), there is an extra step through 7.x — covered in the same guide.
Frequently asked questions
Can I jump from PHP 7.4 straight to 8.4?
Technically yes — Rector has a cumulative set (withPhpSets(php84: true)) that applies the rules of all transitions. In practice, for medium and large projects it is better to go step by step: regressions are easier to attribute to a specific change, and the framework is upgraded in line with its version requirements.
What breaks most often on the 7.4 → 8.0 transition?
- Three things: exceptions where there used to be warnings (string and arithmetic functions), changed string-to-number comparisons (
0 == "abc"stopped being true), and removed functions (each(),create_function(),preg_replacewith/e). The first two need logic review; the third is mostly automatic.
How long does a PHP 7.4 → 8.4 migration take?
- Small project: 1–2 weeks. Medium system with a framework: 4–10 weeks (including framework and library upgrades). Large untested system: 2–4 months in stages. Rector performs the code refactoring itself in hours — the rest is tests, manual fixes and deployments.
Do I have to upgrade the framework right after the PHP migration?
Not "right after", but in the same journey. Frameworks have their own PHP support windows — e.g. Laravel 8 runs on PHP 8.0 but not on 8.2+. If you stay on an old framework with new PHP, you lose framework support. The best rhythm: at every PHP step, raise the framework by the version it supports.
Are dynamic properties a blocker for migrating to 8.2?
No — it is a deprecation, the code keeps working. But it clutters the logs and will become an error in a future version. The most common sources are old ORMs and code "attaching" fields to objects. The fix: explicit field declarations or #[AllowDynamicProperties] as a conscious buffer with a review date.
Will migrating from 7.4 affect application performance?
- Usually positively: PHP 8.x is faster than 7.4 by tens of percent depending on the profile (JIT, engine optimizations, faster functions). CPU-bound applications see the biggest gain; database-bound applications less. The full effect also requires framework and library upgrades.