Upgrade guide: PHP 8.1 to 8.4

PHP 8.1 to 8.4: three releases and a longer runway

Moving from PHP 8.1 to 8.4 crosses three releases and leaves you on a branch with security fixes until 31 December 2028. The work centres on 8.2's undeclared property deprecation and on 8.4's nullable types and unbundled extensions. Because 8.1 has no PHP2Shell release, patch or contain the current server first.

Last reviewed against php.net's guides for 8.2, 8.3, 8.4 and 8.5 and the PHP2Shell advisories.

Fix first, then migrate

Separate the 8.1 security fix from the 8.4 project

Three releases of code changes deserve careful testing, and that should not leave an exposed 8.1 server online while it happens. The PHP project's fixed releases start at 8.2.35; for 8.1 the routes to a fix are narrower.

  • A supplier's patched 8.1. php.net notes that some distributions and long-term support vendors backport fixes to branches it has retired. Ask yours, quoting CVE-2026-109618 and CVE-2026-109619.
  • The PHP security team's source backport. Branch PHP-8.1-CVE-2026-109618-109619 in php-src, for teams that build PHP; it carries no new version number.
  • Containment. CISA's position is that PHP systems on 8.1 or earlier should be taken offline; the risk calculator helps you weigh what that means for your site.

Why 8.4 from 8.1

  • Support to the end of 2028. Security fixes for 8.4 run until 31 December 2028, one year beyond 8.3.
  • One release more than 8.3. 8.4 adds its own deprecations and the extension removals on top of 8.2 and 8.3.
  • Active support ends soon, and that is fine. 8.4 moves to security fixes only once 31 December 2026 has passed, the date 8.2 reaches end of life.
  • Build to install: 8.4.27 at minimum, since earlier 8.4 builds lack the PHP2Shell fix.

Need the shorter hop? PHP 8.1 to 8.3. All routes: upgrade hub.

Release 1 of 3

PHP 8.2 changes that matter for 8.1 code

Objects can no longer grow properties silently

What changed: PHP 8.2 deprecates creating a property that the class definition does not list. stdClass is exempt, and so is any class that routes property access through __get() and __set(). Adding #[\AllowDynamicProperties] to a class restores the old behaviour for it and its subclasses.

What to look for: Each notice points at the line that creates the property, so the log doubles as a to-do list. Declare what belongs to the class; for data you attach to objects you do not own, php.net suggests a WeakMap.

Interpolation with ${

What changed: Two interpolation shapes, "${name}" and "${expression}", are deprecated; "$name" and "{$name}" stay valid.

What to look for: Quoted strings and heredoc templates in views, mailers and query builders.

Callables that point relative to a class

What changed: String and array callables using self, parent or static, such as "parent::boot" or ["static", "make"], are deprecated.

What to look for: Hook registrations and array_map() or usort() callbacks inside classes.

Text conversions

What changed: utf8_encode() and utf8_decode() are deprecated, and PHP's case-conversion and case-insensitive search functions now treat only ASCII letters as having case.

What to look for: Encoding fixes in import code, and name or address formatting that relied on a locale set with setlocale().

Key sorting and splitting

What changed: ksort() and krsort() now compare numeric string keys by PHP 8's standard rules under SORT_REGULAR, and str_split() returns an empty array for an empty string.

What to look for: Menus, reports and exports that depend on key order, and checks that expected [''] back from an empty split.

php.net: migrating to 8.2

Release 2 of 3

PHP 8.3 changes on the way through

Date problems arrive as exceptions

What changed: Instead of warnings, the date extension now raises errors and exceptions from its own DateError and DateException families. DateTime::modify() given nonsense throws DateMalformedStringException.

What to look for: Every place that builds a date from user input or an external feed and tests the result for false.

range() is validated

What changed: Non-scalar limits raise TypeError; a zero step, a negative step on a rising range, or infinite and NAN values raise ValueError; mixed digit-and-letter limits now produce characters.

What to look for: Pagination, year pickers and alphabet indexes built with range().

Inheritance details

What changed: Static properties declared in a trait are now redeclared per class that uses the trait, and the visibility of class constants inherited from interfaces is now checked properly.

What to look for: Trait-based registries, and classes that narrow the visibility of an interface constant.

Deprecated calls

What changed: get_class() and get_parent_class() without an argument, ReflectionProperty::setValue() with one argument, and ++ or -- on empty or non-numeric strings.

What to look for: static::class, setValue(null, $value) for static properties, and str_increment() are the replacements.

Output and serialisation

What changed: unserialize() warns where it used to raise notices and also warns about trailing data. highlight_string() and highlight_file() now wrap their HTML in <pre> and stop converting spaces to entities.

What to look for: Cached serialised values from older releases, and pages that style highlighted source code.

php.net: migrating to 8.3

Release 3 of 3

PHP 8.4: the release that decides how long testing takes

Most of what can actually stop an 8.1 application on 8.4 sits in this release: extensions that are no longer there, names that now clash and stricter handling of a few long-standing constructs.

Write nullable types explicitly

What changed: 8.4 deprecates a typed parameter that becomes nullable only through a null default, as in Request $request = null.

What to look for: Search for = null in typed parameter lists. Prefix the type with ? or write Type|null; both run on 8.1 already, so the change can go live before the upgrade.

Four extensions move to PECL

What changed: IMAP, OCI8, PDO_OCI and PSpell are no longer shipped with PHP. The cards below cover each one.

What to look for: Any call into those extensions, plus deployment scripts that install them from your distribution's PHP packages.

exit() and die() take typed arguments

What changed: They now behave much like functions: strict_types applies, normal type coercion applies, and an invalid argument throws TypeError.

What to look for: Exit calls given arrays or objects, including those in error paths that rarely run during testing.

Error levels tidied up

What changed: E_STRICT has gone from the engine and its constant is deprecated; raising E_USER_ERROR through trigger_error() is deprecated.

What to look for: error_reporting() masks and handler switch statements that name E_STRICT.

CSV calls need the escape argument

What changed: Calling fputcsv(), fgetcsv() or str_getcsv() without an explicit $escape now logs a deprecation, as do the equivalent SplFileObject methods unless setCsvControl() already set one.

What to look for: Import and export code; the example below shows the explicit form.

Fresh global names

What changed: New global functions, including array_find(), array_any(), array_all(), array_find_key(), mb_trim() and mb_ucfirst(), and new global classes Deprecated and RoundingMode.

What to look for: Polyfill libraries and helper files that declare the same names unconditionally; they now stop with a redeclaration error.

readonly properties inside __clone()

What changed: Taking a reference to a readonly property inside __clone(), for example $ref = &$this->items, is no longer allowed.

What to look for: Value objects written for 8.1's readonly properties that adjust themselves while being cloned.

PDO MySQL attributes become booleans

What changed: PDO::ATTR_AUTOCOMMIT, PDO::ATTR_EMULATE_PREPARES and PDO::MYSQL_ATTR_DIRECT_QUERY are now set and returned as booleans rather than integers.

What to look for: Code that compares getAttribute() results with 1 or 0 using ===.

Other deprecated signatures

What changed: session_set_save_handler() with more than two arguments, the DatePeriod constructor that takes an ISO string, stream_context_set_option() with two arguments, and unserialising strings that use the uppercase S tag.

What to look for: Custom session handlers registered with separate callbacks; pass a SessionHandlerInterface object instead.

<?php
$out = fopen('php://output', 'w');

// Deprecated in 8.4: the escape argument is left at its default
fputcsv($out, ['id', 'name']);

// Explicit: pass the escape character, or "" to switch PHP's escaping off
fputcsv($out, ['id', 'name'], ',', '"', '');

php.net: 8.4 incompatible changes, 8.4 deprecations, 8.4 other changes.

Unbundled in 8.4

Four extensions that leave the PHP distribution

php.net moved these to PECL with 8.4 and says new PECL releases will be published as users need them. For each, either install the PECL build on the server, where your host or distribution makes that possible, or replace the dependency in code before the switch.

3D envelope with a red alert, for the IMAP extension leaving PHP

IMAP

Reading and managing mailboxes over IMAP, POP3 or NNTP.

Look for: imap_open() and the other imap_* functions, for example in code that reads a support mailbox.

3D server rack, for the OCI8 extension leaving PHP

OCI8

Native connections to Oracle Database.

Look for: oci_connect(), oci_pconnect() and the other oci_* functions.

3D hosting servers in the cloud, for PDO_OCI leaving PHP

PDO_OCI

Oracle access through the PDO interface.

Look for: PDO connection strings beginning oci:, such as oci:dbname=//db.example:1521/orders.

3D stack of books with a padlock, for the PSpell extension leaving PHP

PSpell

Spell checking; building it needs the aspell library.

Look for: pspell_new() and the other pspell_* functions.

Source: php.net, removed extensions in PHP 8.4. A quick check on a server: php -m lists the modules the command line PHP has loaded.

Before the next step

What to clear on 8.4 before 8.5

Finish with an empty deprecation log on 8.4 if you can, then check the code against these 8.5 deprecations, which can be fixed in a way that still runs on 8.4.

  • null as an array key. Using null as an array offset, or passing it to array_key_exists(), is deprecated in 8.5; php.net says to use an empty string.
  • Incrementing non-numeric strings. Soft-deprecated in 8.3, formally deprecated in 8.5. str_increment() already exists on 8.4.
  • mysqli_execute(). This alias is deprecated; call mysqli_stmt_execute().
  • Redeclaring constants. Already a warning, and deprecated from 8.5.

php.net: features deprecated in 8.5

Tooling

Static checks for an 8.1 to 8.4 move

Three open source tools cover most of the search work. None of them executes the application, so pair them with the staging tests below.

  • Rector. withPhpSets(php84: true) applies every PHP set up to and including 8.4, and vendor/bin/rector process --dry-run shows the diff before anything is written.
  • PHPCompatibility, a PHP_CodeSniffer standard. With testVersion set to 8.4 it reports use of removed and deprecated features. Bear in mind its PHP 8 rules ship in the 10.0 pre-release series (alpha2 in November 2025); the last stable release, 9.3.5, is from 2019.
  • PHPStan. phpVersion: 80400 in phpstan.neon analyses the code as 8.4 code. Left unset, PHPStan takes config.platform.php from composer.json, so the platform setting in the test plan below steers it too.
# rector.php (PHP sets are cumulative, so php84 includes 8.2 and 8.3)
#   ->withPhpSets(php84: true)
vendor/bin/rector process --dry-run

vendor/bin/phpcs -p . --standard=PHPCompatibility --runtime-set testVersion 8.4

# find your own definitions that collide with 8.4's new names
grep -rnE "function (array_find|array_any|array_all|array_find_key|mb_trim|mb_ucfirst)\(" src/

Documentation: Rector set lists, PHPCompatibility, PHP_CodeSniffer, PHPStan.

Testing and rollout

Six checks to pass on staging

Remember that the php.ini-production file shipped with 8.4 leaves E_DEPRECATED out of error reporting; staging needs the full level.

Check How Pass when
Runtime Staging on 8.4.27 or later; read the version from both php -v and a web phpinfo() page Both show the fixed 8.4 build
Extensions Compare php -m and phpinfo() between the 8.1 and 8.4 servers Same modules, with PECL builds or replacements for the four unbundled ones
Dependencies config.platform.php set to 8.4.27, composer update, then composer check-platform-reqs on staging No unmet platform requirements on the real runtime
Syntax php -l on every file with the 8.4 binary No parse errors
Logs error_reporting = E_ALL, log_errors = On, display_errors = Off No fatal errors; each remaining deprecation has an owner
Journeys Sign-in, checkout or enrolment, forms, uploads, email, cron, queues and CLI scripts Every journey completes with the log open

Going live

Pick a low-traffic window, leave the 8.1 runtime installed but unused, switch, and keep the PHP error log in view for the rest of the day. Then check the version a web request actually reports.

Rolling back safely

Know in advance how you would switch back and what data changes you would reverse. The runtime you fall back to must be the backported 8.1, never the original build: an unpatched 8.1 puts both PHP2Shell CVEs, now listed by CISA as exploited, back on the internet. Fix forward as soon as you can.

Platform constraints

Platform releases between 8.1 and 8.4

Platform On 8.1 today Runs on 8.4
Drupal10.4 to 10.6The same releases (8.1 to 8.4), or 11.1 to 11.4
Magento 22.4.4 to 2.4.62.4.8 (8.3 and 8.4)
PrestaShop8.0 to 8.2 (8.1 recommended)9.0 (8.4 recommended) or 9.1
Laravel10 (8.1 to 8.3)11, 12 or 13
Moodle4.4 and 4.5 (8.1 minimum)5.0 and later
Joomla5.x (8.1 minimum)5.2 and later announced full 8.4 compatibility; 6.x recommends 8.4
OpenMage20.14.0 and later (8.1 minimum)The same line; 8.4 supported, some warnings possible
Nextcloud and WooCommerceCheck your releaseNextcloud 33 to 35; WooCommerce is tested up to 8.4

Then install 8.4 with the PHP update guides: Linux, cPanel, Plesk, Docker, PHP-FPM, LiteSpeed and Windows and IIS. Sibling guides: 8.1 to 8.3, 8.0 to 8.4, 8.2 to 8.4, 8.2 to 8.3, 7.4 to 8.4 and 7.4 to 8.3.

Quick answers

PHP 8.1 to 8.4 questions

Can I skip PHP 8.2 and 8.3 entirely?

In production, yes: install 8.4 directly. In the code, no: the 8.2 and 8.3 changes still apply, so read php.net's guides for 8.2, 8.3 and 8.4 in turn and test against 8.4 with everything logged.

What does the 8.4 implicit nullable deprecation mean for my code?

Any parameter written as Type $name = null now logs a deprecation, because the null default quietly widens the declared type. Change it to ?Type $name = null or Type|null $name = null. Both forms already work on 8.1, so you can fix them in production before the upgrade.

Will Drupal 10 run on PHP 8.4?

Drupal 10.4, 10.5 and 10.6 support PHP 8.1 to 8.4 according to drupal.org, so a Drupal 10 site on one of those releases can move from 8.1 to 8.4 without changing its Drupal major version. Drupal 11.1 and later need 8.3 or 8.4.

Drupal PHP requirements

How do I keep PHP 8.1 safe while the 8.4 upgrade is in testing?

Run an 8.1 build that carries the PHP2Shell backport, from your supplier or compiled from the PHP security team's branch. Add the PHP project's interim settings, limit who can reach the site where possible, and check the server for signs of compromise if it ran unpatched on the internet.

Signs of compromise

Does PHP 8.4 need a newer Apache?

If you run PHP as an Apache module, yes: from 8.4 the Apache handler no longer supports Apache 2.0 or 2.2 and needs Apache 2.4. php.net also recommends against mod_php for new installations, so the upgrade is a good point to move to PHP-FPM.

Apache and PHP

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.