Upgrade guide: PHP 7.4 to 8.4

PHP 7.4 to 8.4: the longest jump, and how to land it safely

Going from PHP 7.4 to 8.4 crosses five branches in one move and buys security support until 31 December 2028. It also stacks the PHP 8.0 break on top of 8.4's own removals, including four extensions taken out of the core. Close PHP2Shell on the 7.4 server first, then migrate on a test copy.

Last reviewed against php.net's migration guides from 8.0 through 8.5 and the PHP2Shell advisories.

Two projects, not one

Patch the 7.4 server now; migrate the code on your own timetable

The PHP2Shell fixes released on 21 October 2026 exist for 8.2, 8.3, 8.4 and 8.5. PHP 7.4 has been end of life since 28 November 2022 and got no release. A five-branch migration needs careful testing, so the server needs protecting while that happens.

  • Supplier packages. Ask whoever provides your PHP (Linux distribution, host or long-term support vendor) whether their 7.4 package includes the backported fix.
  • Self-built PHP. Compile from the PHP security team's PHP-7.4-CVE-2026-109618-109619 branch, a minimal source-only backport.
  • No patch available. CISA advises taking PHP systems offline if they run 8.1 or earlier, or cannot be updated immediately.

Is 8.4 the right target?

From 7.4, both of these are reasonable. Your application's supported versions usually decide it.

Target Security fixes until Lowest fixed build Trade-off
PHP 8.3 31 Dec 2027 8.3.36 One release fewer to absorb; four extensions stay bundled
PHP 8.4 31 Dec 2028 8.4.27 A further year of fixes; leaves active support on 31 Dec 2026

Prefer the shorter route? Read PHP 7.4 to 8.3. Every starting point is listed on the upgrade hub.

How 7.4 code fails on 8.4

Four kinds of breakage, found four different ways

Sorting problems by how they show up helps you pick the right test for each. The release-by-release detail follows below.

3D code windows flagged with bugs, for code that will not compile

It will not compile

Brace offsets, unbracketed nested ternaries and the removed casts stop a file loading at all. They are the easiest to find: a syntax check with the 8.4 binary lists every one.

3D servers going down and coming back, for requests that stop part way

It stops part way through a request

Removed functions, undefined constants, wrong argument counts and invalid types now throw. These show up as fatal errors in the log the first time the code path runs.

3D hooded figure holding masks, for code that runs but gives different answers

It runs, but answers differently

Number and string comparisons, locale-blind case conversion and database values arriving as integers change results without any error. Only tests with real assertions catch them.

3D megaphone with icons, for deprecations flooding the log

It works, but floods the log

Deprecation notices from 8.1, 8.2 and 8.4 do not break a page, but they bury the messages that matter. Triage them by message, not by line.

Release 1 of 5

PHP 8.0: the changes that hit 7.4 code hardest

Expect to spend the largest share of the project here. PHP 8.0 finished off what 7.x deprecated and tightened the engine in ways that older plugins and libraries can trip over.

Text-to-number comparisons give new answers

What changed: A loose comparison between a number and a non-numeric string is now made as a string comparison. On 7.4 the string was converted to a number first, so "abc" == 0 was true; from 8.0 it is false, and 42 == "42foo" turns false the same way.

What to look for: Validation that treats 0, false and empty input as the same thing, in_array() lookups over mixed types and switch statements on values that may be numbers or words. ===, or the strict flag on array searches, states the intent on both versions.

Associative arrays in call_user_func_array()

What changed: With named arguments arriving in 8.0, call_user_func_array() reads string keys in its argument array as parameter names. 7.4 ignored the keys and passed values by position.

What to look for: Argument lists built as associative arrays, for example from decoded JSON or a configuration file, and handed to call_user_func_array(). Use array_values() if position is what you meant.

Inheritance is checked strictly

What changed: A child method whose signature is incompatible with its parent is now always a fatal error, where some cases only warned before. Abstract methods declared in traits are checked against the class using them, and magic methods that declare types must match the signatures php.net documents.

What to look for: Plugin, theme and extension classes that override framework methods with extra or missing parameters, and __get() or __set() declared with types other than the documented ones.

Removed functions and changed signatures

What changed: each(), create_function(), money_format() and get_magic_quotes_gpc() are among the functions deleted. implode() no longer takes its arguments the wrong way round, parse_str() and mb_parse_str() require a result array, mktime() needs at least one argument, and array_key_exists() refuses objects.

What to look for: A text search finds the deleted names. The signature changes need closer reading: implode($parts, ', ') with the glue last, parse_str($query) with no second argument, array_key_exists() on an object (use property_exists() or isset()).

Arithmetic on text and arrays

What changed: Arithmetic on a string with no numeric content now throws TypeError, while a string that merely starts with a number, such as "5 apples", gives a warning where 7.4 gave a notice. Arithmetic and bitwise operators on arrays, resources or ordinary objects throw too; joining two arrays with + still works.

What to look for: Totals, prices and counters calculated from form fields or spreadsheet columns that may hold text.

Mistakes that used to be tolerated

What changed: An undefined constant now throws Error instead of turning into a string, and so does writing to a property of null, false or an empty string. Reading undefined variables, array keys or properties is promoted from a notice to a warning.

What to look for: Your existing 7.4 error log is a good map: each notice of these kinds is now either a louder warning or a stopped request.

PDO stops failing quietly

What changed: New PDO connections now default to exception mode, so a database error raises PDOException instead of returning false.

What to look for: Data-access layers that never set PDO::ATTR_ERRMODE and rely on checking return values.

Defaults that change what visitors see

What changed: Without a php.ini setting to say otherwise, error_reporting now defaults to E_ALL and display_startup_errors is on; display_errors also defaults to on. php.net reminds administrators to keep display_errors=Off in production.

What to look for: Freshly built servers or containers started without a php.ini, which can print error details to visitors.

Modules and extensions

What changed: XML-RPC is no longer part of PHP and lives in PECL. Apache's PHP module is now registered as php_module, not php7_module.

What to look for: xmlrpc_* calls, and <IfModule php7_module> sections in Apache configuration whose contents are skipped once PHP 8 is loaded.

php.net: incompatible changes in 8.0, deprecations in 8.0.

Releases 2 and 3 of 5

PHP 8.1 and 8.2: deprecations, plus a few quiet behaviour changes

PHP 8.1

Null into built-in functions

What changed: A scalar parameter of a built-in function that is not declared nullable now logs a deprecation when it receives null.

What to look for: Calls to str_replace(), explode(), strpos() and similar fed from optional inputs. Normalising the value where it enters the code, with a cast or a ?? '' default, is often tidier than patching each call.

Shared static variables

What changed: A static variable inside a method is now shared between the parent class and any child class that inherits the method without overriding it.

What to look for: Per-class counters, caches or instances held in static $instance inside a base-class method.

Optional parameters before required ones

What changed: 8.0 deprecated a default value on a parameter followed by a required one; from 8.1, calling such a function with named arguments throws ArgumentCountError.

What to look for: Signatures like function make($size = 'm', $colour). Remove the default, which never had any effect.

Database values change type

What changed: With PDO MySQL's emulated prepares, and with PDO SQLite, integer and float columns now come back as PHP numbers rather than strings. mysqli switches to throwing exceptions by default.

What to look for: Strict comparisons such as $row['active'] === '1', and API responses whose field types change from strings to numbers. PDO::ATTR_STRINGIFY_FETCHES brings strings back if you need time.

Overriding PHP's own classes

What changed: Methods that override built-in class methods need compatible return types, or a deprecation notice appears; #[\ReturnTypeWillChange] mutes it for now.

What to look for: Custom iterators, collections and DateTime subclasses in older libraries.

PHP 8.2

Properties created on the fly

What changed: Assigning a property that the class does not declare is deprecated unless the class opts in with #[\AllowDynamicProperties], an attribute child classes inherit.

What to look for: Data objects and service classes that bolt on fields at runtime. Declare them; reach for the attribute only as a bridge.

Interpolation and callables

What changed: "${name}" style interpolation is deprecated, as are callables such as "parent::method" and ["self", "method"].

What to look for: String templates and callback arrays inside classes; "{$name}" and [static::class, 'method'] are the modern forms.

Files and directories

What changed: glob() returns an empty array, not false, when open_basedir blocks every path. If you pass flags to FilesystemIterator, SKIP_DOTS must now be included explicitly to keep skipping . and ...

What to look for: glob(...) === false checks on hosts that set open_basedir, and directory walkers that construct FilesystemIterator with custom flags.

Text handling

What changed: Case functions such as strtolower() and ucwords() work on ASCII letters only, utf8_encode() and utf8_decode() are deprecated, and str_split('') returns an empty array.

What to look for: Locale-dependent formatting of names and addresses, and old Latin-1 import code.

php.net: migrating to 8.1, migrating to 8.2.

Releases 4 and 5 of 5

PHP 8.3 and 8.4: a light step, then a heavier one

PHP 8.3

Static properties declared in traits

What changed: A class that uses a trait with a static property now gets its own copy of that property instead of sharing the parent class's.

What to look for: Registries or counters kept in a trait's static property across a class hierarchy.

Negative keys on an empty array

What changed: After assigning a negative index $n to an empty array, the next appended element gets $n + 1 rather than 0.

What to look for: Code that seeds an array with a negative key and then appends with [].

Dates and ranges

What changed: Invalid date strings passed to DateTime::modify() now throw DateMalformedStringException instead of returning false, and range() rejects bad boundaries and steps with exceptions.

What to look for: Date handling that tests for false, and ranges built from user-supplied limits.

Small deprecations

What changed: get_class() without an argument and ++ on empty or non-alphanumeric strings are deprecated.

What to look for: Replace with self::class or static::class, and with str_increment().

PHP 8.4

8.4 is where code that was fine on 8.3 can still fail, mainly through the unbundled extensions and new global names. Its implicit nullable deprecation, though, is one you can fix today on 7.4:

<?php
// Before, deprecated in PHP 8.4: the null default silently widens the type
// function notify(User $user, Mailer $mailer = null) {}

// After: explicit, and accepted by every release from 7.1 to 8.5
function notify(User $user, ?Mailer $mailer = null) {}

Implicitly nullable parameters

What changed: A typed parameter with a default of null but no nullable type is deprecated.

What to look for: Every Type $param = null in your code and dependencies. Write ?Type $param = null, which works on 7.1 and later, so the fix can ship before the switch.

exit() and die() behave like functions

What changed: They can now be passed as callables, follow strict_types, and throw TypeError when given a value of the wrong type instead of quietly converting it to a string.

What to look for: die($errors) where the variable might be an array or object.

Four extensions leave the core

What changed: IMAP, OCI8, PDO_OCI and PSpell have moved to PECL and are no longer part of PHP. php.net says new PECL releases will appear as demand requires.

What to look for: imap_open(), oci_connect(), PDO connection strings starting oci: and pspell_new(). Each one needs a PECL build on the server or a replacement in the code.

Error levels

What changed: The E_STRICT level has been removed and its constant deprecated, and trigger_error() with E_USER_ERROR is deprecated.

What to look for: Custom error handlers and logging code that list E_STRICT, and fatal user errors raised with trigger_error(); throw an exception or call exit() instead.

CSV functions want an explicit escape

What changed: Relying on the default $escape argument of fputcsv(), fgetcsv() and str_getcsv(), and of the matching SplFileObject methods, is deprecated.

What to look for: Every CSV import and export. Pass the escape character, or an empty string to turn PHP's own escape mechanism off.

New names that may collide

What changed: 8.4 adds global functions such as array_find(), array_any(), array_all(), array_find_key(), mb_trim(), mb_ucfirst() and fpow(), and global classes Deprecated and RoundingMode. php.net warns that any new name can cause a redeclaration error.

What to look for: Helper files and polyfills that define functions or classes with these names without a function_exists() or class_exists() guard.

Sessions and mysqli

What changed: Changing session.sid_length, session.sid_bits_per_character, session.use_trans_sid or session.use_only_cookies is deprecated, as is the SID constant. mysqli_ping(), mysqli_kill() and mysqli_refresh() are deprecated too.

What to look for: Session bootstrap code that tunes ID length, URL-based session IDs, and connection keep-alive loops built on mysqli_ping().

Runtime configuration

What changed: The JIT is now switched off through opcache.jit=disable with a default opcache.jit_buffer_size of 64M, and Apache's PHP module requires Apache 2.4.

What to look for: Servers that enabled the JIT by setting a buffer size alone; they must now set opcache.jit as well.

php.net: migrating to 8.3, 8.4 incompatible changes, 8.4 deprecations, 8.4 removed extensions.

Before the next step

Deprecations to clear before PHP 8.5

Arriving on 8.4 with a long list of muted deprecations only moves the work to the next upgrade. Clear the 8.1, 8.2 and 8.4 notices from the staging log while the code is fresh in mind, then look ahead: these 8.5 deprecations target older coding habits.

  • The backtick operator. Running a shell command with backticks, an alias for shell_exec(), is deprecated in 8.5.
  • Long cast names. (integer), (boolean), (double) and (binary) are deprecated in favour of (int), (bool), (float) and (string).
  • Semicolons after case. case 1; instead of case 1: is deprecated.

Full list: php.net, PHP 8.5 deprecated features.

Tooling

Tools for a five-release jump, used in stages

On a jump this long, running every rule at once produces a change set too large to review. Take the tools a step at a time and commit between steps. All of them are open source; none of them runs your code, so they complement testing rather than replace it.

  • Rector, by level. Rector's documentation advises legacy projects to use withPhpLevel() and raise it one step at a time rather than enabling a whole PHP set at once. Each level's --dry-run diff is smaller and easier to review.
  • PHPCompatibility for PHP_CodeSniffer. Run it with --runtime-set testVersion 8.4 to list code that uses features removed or deprecated by 8.4. The rules for PHP 8 releases are in its 10.0 development line, which had reached 10.0.0-alpha2 in November 2025; the older 9.3.5 stable release predates PHP 8.
  • PHPStan. Set phpVersion: 80400 in its configuration to analyse the code as PHP 8.4 code, whichever PHP runs the analysis.
  • Composer and php -l. composer why-not php 8.4 names packages that block the target, and a lint pass with the 8.4 binary catches syntax that no longer parses.
<?php
// rector.php: start at level 0, then raise it by one per reviewed commit
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPhpLevel(0);
# preview the rewrites for the current level, then review and commit
vendor/bin/rector process --dry-run

# list code using features removed or deprecated by 8.4
vendor/bin/phpcs -p src --standard=PHPCompatibility --runtime-set testVersion 8.4

# which packages refuse PHP 8.4?
composer why-not php 8.4

Project sites: PHPCompatibility, PHP_CodeSniffer, Rector, PHPStan.

Testing and rollout

Five checkpoints between 7.4 and a live 8.4 site

Build the staging server on 8.4.27 or later, the first 8.4 release with the PHP2Shell fix, then pass each checkpoint before moving to the next.

  1. Checkpoint 1: the code compiles under 8.4

    Lint every PHP file with the 8.4 binary. Nothing else is worth testing until this passes, because a parse error stops a whole file from loading.

  2. Checkpoint 2: dependencies resolve for 8.4

    Point config.platform.php in composer.json at 8.4.27, run composer update, and use composer why-not php 8.4 for any package that refuses. On the staging server, composer check-platform-reqs then checks the real PHP and extensions, which the platform setting cannot do.

  3. Checkpoint 3: every extension is present

    List the 7.4 server's modules with php -m and from phpinfo() on a web page, since the two can differ, then match them on 8.4. Anything from the unbundled four, or XML-RPC, needs a PECL build or a code change.

  4. Checkpoint 4: a clean log under full reporting

    Run staging with error_reporting = E_ALL, log_errors = On and display_errors = Off. The php.ini-production file shipped with 8.4 excludes E_DEPRECATED, so change that line on staging. Exercise every journey, scheduled job and command line script, then work the log down to zero fatal errors and a known list of deprecations.

  5. Checkpoint 5: a timed cutover

    Switch production in a quiet window with the old runtime still installed, watch the log for the first hours and confirm the running version afterwards.

Rebuilding anyway? Reconsider mod_php

php.net's Apache installation guide says not to use mod_php for new installations, and the PHP project rates the PHP2Shell risk highest under mod_php. A 7.4 to 8.4 rebuild is a natural moment to move to PHP-FPM.

Apache and PHP-FPM guides

Rolling back without reopening PHP2Shell

Decide before the cutover what would trigger a rollback and who makes the call. The fallback must be a 7.4 build carrying the backport: an unpatched 7.4 brings back two flaws that CISA lists as known to be exploited. Keep the old runtime installed but idle, note any database changes you would need to reverse, and aim to roll forward with a fix rather than linger on 7.4.

Platform constraints

Which platforms are ready for PHP 8.4

Apart from WordPress and WooCommerce, the platforms below list PHP 8.4 support only in releases that no longer run on 7.4, so the application upgrade is part of the move.

Platform Releases that support PHP 8.4
Magento 22.4.8 (PHP 8.3 and 8.4); 2.4.9 lists 8.5
OpenMage8.1 or later required since 20.14.0; 8.4 and 8.5 supported, though some warnings may be logged
PrestaShop9.0 (8.4 recommended) and 9.1; 8.x stops at 8.1
WooCommerceRequires 8.3 or greater, tested up to 8.4; WordPress 7.0 and 7.1 run on 7.4 to 8.5
Drupal10.4 to 10.6 and 11.1 to 11.4
Joomla6.x (8.4 recommended); Joomla announced full 8.4 compatibility in 5.2
Laravel11, 12 and 13
Moodle5.0 and later
Nextcloud33 (8.4 recommended), 34 and 35

For installing 8.4 itself, use the server update guides: Linux, cPanel, Plesk, Docker, LiteSpeed, Windows and IIS and WordPress. Other routes: 8.0 to 8.4, 8.1 to 8.4, 8.2 to 8.4, 8.1 to 8.3 and 8.2 to 8.3.

Quick answers

PHP 7.4 to 8.4 questions

Is one jump from 7.4 to 8.4 riskier than two smaller upgrades?

The code changes are identical either way: you clear the 8.0 to 8.4 changes whichever route you take. The difference is in production. Two moves, to 8.3 now and 8.4 later, means two cutovers and two rounds of testing. One move to 8.4 means a longer test phase and one cutover. With 7.4 out of support, choose whichever reaches a fixed, supported runtime sooner for your application.

The PHP 7.4 to 8.3 guide

PHP 8.4 leaves active support on 31 December 2026: is it still a sensible target?

Yes. Leaving active support means the end of routine bug-fix releases, not of security fixes: the PHP project keeps releasing fixes for critical security issues on 8.4 until 31 December 2028, a year longer than 8.3. 8.5 runs to 31 December 2029 if your software already supports it.

PHP end of life dates

My application uses IMAP or connects to Oracle: can it run on PHP 8.4?

Only with extra work. IMAP, OCI8 and PDO_OCI are no longer part of PHP from 8.4; they are maintained in PECL, which php.net says will publish new releases according to demand. Either install the PECL extension on the server, if your host or distribution allows it, or replace the code that depends on it before the switch.

Can a backported 7.4 stay live while the 8.4 migration is tested?

A 7.4 build that carries the PHP2Shell backport closes these two flaws, but the branch is still end of life and will get nothing for the next one. Keep that period short, apply the PHP project's interim settings as an extra layer, and restrict who can reach the site if you can.

The interim settings

How long will a PHP 8.4 upgrade last before I have to do this again?

Security support for 8.4 runs to 31 December 2028. Plan the next move during 2028 at the latest; clearing 8.4's deprecations as you go, and keeping an eye on 8.5's list, makes that step much smaller than this one.

3D rocket illustration above the call to find every exposed PHP server

Know your exposure

Find every PHP server you run

Patching the server you know about is the easy part. A vulnerability scan finds the forgotten staging site, the old microsite and the appliance with PHP inside, then tells you which ones are still exposed.