Upgrading from PHP 8.2 to 8.4: what breaks and how to fix it
By Ryan Murdoc, founder of Unstale · Published 13 October 2026 · Last checked October 13, 2026 · 5 min read
PHP 8.2 security support ends December 31, 2026.
PHP 8.2 stops getting security fixes on December 31, 2026. After that, new vulnerabilities in 8.2 won't be patched, and hosting providers start retiring it. PHP 8.4 is supported until the end of 2028.
The jump goes through 8.3, but you don't need to stop there: upgrade straight to 8.4 and fix what both releases changed. For most apps the language changes are small. The real work is dependencies and a long tail of deprecation warnings.
Before you start
- Get the test suite passing on 8.2 today. You need a known-good baseline. If a test fails before you change anything, write it down so you don't chase it later.
- Make deprecations visible. Run tests with
error_reporting=E_ALLanddisplay_errors=1, or set PHPUnit'sfailOnDeprecation="true". Most PHP 8.4 work shows up as deprecations, not errors. - Check your framework. Laravel 11 supports PHP 8.2–8.4; Laravel 12 supports 8.2–8.5; Laravel 13 needs 8.3 or newer. If you're on Laravel 10, upgrade the framework first: Laravel 10 does not support PHP 8.4.
- Tell Composer the target. Set
"config": {"platform": {"php": "8.4.0"}}incomposer.jsonand runcomposer update. Composer will show every package that blocks 8.4.
1. Implicitly nullable parameters (the big one)
PHP 8.4 deprecates giving a typed parameter a null default without marking the type nullable. Older codebases often have hundreds of these.
// Before: deprecated in 8.4
function save(User $user = null) { ... }
// After
function save(?User $user = null) { ... }
If a parameter like this sits before a required one, drop the default too: function f(A $a, ?B $b, C $c).
How to fix it: don't do it by hand. PHP-CS-Fixer's nullable_type_declaration_for_default_null_value rule, or Rector's ExplicitNullableParamTypeRector, rewrites them all. Review the diff, then run the tests.
2. exit() and die() are now functions
In 8.4, exit() behaves like a function. It respects strict_types and throws a TypeError for arguments that aren't an integer or string, where it used to cast silently.
exit($result); // TypeError in 8.4 if $result is an array, null or bool
exit((int) $result); // explicit
How to find it: search for exit( and die( with a variable argument.
3. Functions that now throw instead of guessing
Several functions used to accept bad arguments and carry on. In 8.4 they throw a ValueError:
round()with an invalid rounding mode (it used to round half-up silently).str_getcsv()when the separator or enclosure isn't exactly one byte.unserialize()whenallowed_classesisn't an array of class names or a bool.
PHP 8.3 did the same to range(): objects, arrays, a zero step or a negative step on an increasing range now throw.
4. Behaviour changes that tests can miss
These don't throw. They return different results, so only a test that checks the exact output will notice.
- Traits with static properties (8.3). Each class using the trait now gets its own copy of the static property. A counter or registry kept in a trait used by several classes will split.
number_format()with negative decimals (8.3) now rounds to the left of the decimal point instead of ignoring the value.- Regular expressions (8.4). The bundled PCRE2 library was updated, and
{,3}is now a quantifier (zero to three), not literal text. Search your patterns for{,. - Dates (8.3). The Date extension throws
DateExceptionandDateErrorsubclasses where it used to emit warnings. Code that checked forfalseafter a warning may now see an exception.
5. Other deprecations to search for
Each of these logs a deprecation in 8.4. None are hard to fix; the trick is finding them all.
| Search for | Replace with |
|---|---|
trigger_error(..., E_USER_ERROR) |
throw an exception, or exit |
E_STRICT |
remove it (the level no longer exists) |
0 ** -1, pow(0, -n) |
fpow() if you want IEEE behaviour |
fgetcsv, fputcsv, str_getcsv without escape |
pass escape explicitly |
lcg_value() |
Random\Randomizer::getFloat() |
mysqli_ping, mysqli_kill, mysqli_refresh |
remove, or use SQL KILL / FLUSH |
new ReflectionMethod('Class::method') |
ReflectionMethod::createFromMethodName() |
get_class() / get_parent_class() with no argument (8.3) |
self::class / parent::class |
++ on an empty or non-alphanumeric string (8.3) |
str_increment() or explicit logic |
6. Name clashes with new functions
PHP 8.4 adds global functions including array_find(), array_find_key(), array_any(), array_all(), mb_trim() and fpow(). If your code or an old helper package defines a function with the same name without a function_exists() guard, PHP stops with a redeclaration error. Search for function array_find and friends.
7. Server and extension changes
- Extensions removed from core: IMAP, OCI8, PDO_OCI and PSpell moved to PECL in 8.4. If you use
imap_*functions, plan a replacement or install the PECL package. - JIT: 8.4 disables JIT with
opcache.jit=disableby default. If you turned JIT on only by settingopcache.jit_buffer_size, also setopcache.jit(for exampletracing). - Sessions: changing
session.sid_length,session.sid_bits_per_character,session.use_trans_sidandsession.use_only_cookiesis deprecated. - MySQL: server wait timeouts now report error 4031 instead of 2006 on MySQL 8.0.24+. Reconnect logic that checks for 2006 needs both.
Upgrade checklist
- Tests pass on 8.2 with deprecations visible
- Framework on a version that supports 8.4
composer updateresolves with the platform set to 8.4- Implicitly nullable parameters fixed with a tool, diff reviewed
- Every search in sections 2–6 run, hits fixed
- Tests pass on 8.4 with zero new deprecations
- Docker image, CI,
.php-versionand hosting runtime updated - Staging deploy checked: main pages, a form submit, a queued job, a scheduled task
Sources
- PHP 8.4 migration guide: backward-incompatible changes, deprecations, removed extensions
- PHP 8.3 migration guide
- PHP supported versions
- Laravel support policy