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-109619in 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.
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.
OCI8
Native connections to Oracle Database.
Look for: oci_connect(), oci_pconnect() and the other oci_* functions.
PDO_OCI
Oracle access through the PDO interface.
Look for: PDO connection strings beginning oci:, such as oci:dbname=//db.example:1521/orders.
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.
nullas an array key. Usingnullas an array offset, or passing it toarray_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; callmysqli_stmt_execute().- Redeclaring constants. Already a warning, and deprecated from 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, andvendor/bin/rector process --dry-runshows the diff before anything is written. - PHPCompatibility, a PHP_CodeSniffer standard. With
testVersionset to8.4it 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: 80400inphpstan.neonanalyses the code as 8.4 code. Left unset, PHPStan takesconfig.platform.phpfromcomposer.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 |
|---|---|---|
| Drupal | 10.4 to 10.6 | The same releases (8.1 to 8.4), or 11.1 to 11.4 |
| Magento 2 | 2.4.4 to 2.4.6 | 2.4.8 (8.3 and 8.4) |
| PrestaShop | 8.0 to 8.2 (8.1 recommended) | 9.0 (8.4 recommended) or 9.1 |
| Laravel | 10 (8.1 to 8.3) | 11, 12 or 13 |
| Moodle | 4.4 and 4.5 (8.1 minimum) | 5.0 and later |
| Joomla | 5.x (8.1 minimum) | 5.2 and later announced full 8.4 compatibility; 6.x recommends 8.4 |
| OpenMage | 20.14.0 and later (8.1 minimum) | The same line; 8.4 supported, some warnings possible |
| Nextcloud and WooCommerce | Check your release | Nextcloud 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.
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.
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.
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.