Upgrade from PHP 7.4 to 8.4: what breaks and in what order

A step-by-step path from PHP 7.4 to 8.4. The breaking changes of 8.0, 8.1, 8.2, 8.3 and 8.4 that hit real sites, with the scan and fix commands to run.

Read this first

PHP 7.4 reached end of life on 28 Nov 2022. The PHP2Shell fixes for it exist only as a source backport branch, and no 7.4 release contains them. The way out is a move to a supported branch. The target is 8.4 (fixed in 8.4.27). See which version to pick and PHP 7.4.

You cannot skip the work between 7.4 and 8.4, but you can do it in one pass. This page lists what each version changed. Every item comes from the migration guides in the PHP manual, linked in the sources.

The order that works

  1. Back up files and the database. Test a restore.
  2. Copy the site to a staging environment that runs 8.4 and has the same extensions.
  3. Update the framework, CMS, plugins and Composer packages to versions that declare 8.4 support. Run composer outdated and composer why-not php 8.4 to see what blocks you.
  4. Scan your own code with PHPCompatibility (commands below).
  5. Fix what the scan finds, starting with the 8.0 items, which break code.
  6. Run staging with error_reporting = E_ALL and log_errors = On. Exercise every flow, then read the log.
  7. Fix deprecations from 8.1 to 8.4. They do not break code today, but they fill logs and the features may be removed in a later version.
  8. Switch production, restart every PHP worker and confirm the version.

PHP 8.0: the changes that break code

8.0 is a major version. Most real breakage sits here.

  • String and number comparison. 0 == "foo" was true and is now false. So are 0 == "" and 42 == "42foo". Search for loose == checks against user input or database values, and use ===.
  • Warnings became exceptions. Arithmetic on a non-numeric string throws TypeError. count() on a non-countable value throws TypeError. An undefined constant throws an Error (7.4 read it as a string). A wrong argument count to a built-in function throws ArgumentCountError.
  • Notices became warnings. Reading an undefined variable, array key or property now logs a warning. Old themes and plugins will fill the log with them.
  • Removed functions. each(), create_function(), __autoload(), money_format(), get_magic_quotes_gpc() and fgetss() are gone. Use foreach, anonymous functions and spl_autoload_register().
  • Removed syntax. $str{0} (use $str[0]), the (real) and (unset) casts, and methods named after their class as constructors (use __construct()).
  • Concatenation precedence. "Sum: " . $a + $b now adds first. Add parentheses where you meant the old order.
  • Named arguments. call_user_func_array() now treats string keys as parameter names instead of ignoring them. Check calls that pass associative arrays.
  • Resources are objects. curl_init() returns a CurlHandle, and the GD and OpenSSL functions return objects too. Code that checks is_resource($ch) now fails. Check for false instead.
  • @ no longer hides fatal errors. Error handlers that test error_reporting() == 0 should test error_reporting() & $errno.
  • Defaults. error_reporting defaults to E_ALL. PDO's default error mode is exceptions.
  • Reserved names. match is a keyword and mixed cannot be a class name. #[ now starts an attribute instead of a comment.
  • Extensions and modules. mbstring.func_overload is gone, and XML-RPC moved to PECL. The Apache module name changed from php7_module to php_module, so old LoadModule and IfModule lines stop matching.
  • Deprecated in 8.0. A required parameter after an optional one, libxml_disable_entity_loader(), and sort callbacks that return a boolean instead of an integer. Use <=> in usort().

PHP 8.1: mostly deprecations, two behavior changes

  • null to built-in functions. Passing null to a non-nullable parameter, such as str_contains($x, null) or trim($x), logs a deprecation. This is the biggest source of log noise on old WordPress code. Cast with (string) $x or use $x ?? ''.
  • mysqli throws. The default error mode changed from silent to exceptions. Wrap calls that relied on silent failure, or restore the old mode with mysqli_report(MYSQLI_REPORT_OFF);.
  • htmlspecialchars() defaults changed to ENT_QUOTES | ENT_SUBSTITUTE, so ' becomes &#039;.
  • Return types of internal methods. Classes that implement ArrayAccess, Iterator, Countable or JsonSerializable need a compatible return type, or the #[\ReturnTypeWillChange] attribute, to avoid a deprecation.
  • Serializable alone is deprecated. Add __serialize() and __unserialize().
  • Other deprecations. strftime(), FILTER_SANITIZE_STRING, appending to false as if it were an array, and implicit float to int conversions that lose precision, such as $a[15.5].
  • PDO with the MySQL and SQLite drivers now returns integers and floats as native types.

PHP 8.2: dynamic properties

  • Dynamic properties are deprecated. Assigning $obj->newField = 1 on a class that never declared newField logs a deprecation. Declare the property. Use #[\AllowDynamicProperties] as a stopgap. stdClass is exempt.
  • "${var}" string interpolation is deprecated. Write "{$var}" or "$var".
  • utf8_encode() and utf8_decode() are deprecated. Use mb_convert_encoding().
  • Relative callables such as "self::method" are deprecated. Use [self::class, 'method'].
  • Case functions such as strtolower() and ucfirst() now ignore the locale. Use mb_* functions for localized text.
  • str_split('') returns an empty array instead of [""].

PHP 8.3: few breaks

  • range() is stricter. A step of 0, a negative step on an increasing range, and infinity or NAN throw ValueError.
  • number_format() with negative $decimals now rounds before the decimal point.
  • A negative first array index changes the next implicit key: $a[-5] then $a[] gives -4, not 0.
  • Date errors now use DateException and DateError classes instead of warnings.
  • Deprecated: get_class() with no arguments, assert_options() and the assert.* INI settings, and ReflectionProperty::setValue() with one argument.

PHP 8.4: the nullable parameter deprecation

  • Implicitly nullable parameters. function f(Foo $a = null) is deprecated. Write ?Foo $a = null. Old libraries trigger this on every load, so update packages before you judge your own code.
  • E_STRICT is gone and trigger_error() with E_USER_ERROR is deprecated. Throw an exception instead.
  • CSV escape. Relying on the default escape argument of fgetcsv(), fputcsv() and str_getcsv() is deprecated. Pass it explicitly.
  • Moved to PECL: IMAP, OCI8, PDO_OCI and PSpell. If you use imap_* functions, install the PECL package or switch libraries.
  • Session settings. Changing session.sid_length, session.sid_bits_per_character, session.use_only_cookies, session.use_trans_sid and related settings is deprecated, as is the SID constant.
  • round() throws ValueError for an invalid mode, and the old pre-rounding was removed, so a few results change.
  • bcrypt cost defaults to 12 instead of 10. Logins take longer. Existing hashes still verify.
  • OPcache JIT defaults changed. If you enabled JIT only through opcache.jit_buffer_size, set opcache.jit too.
  • New names can collide. Any new function or class in 8.4 breaks code that defines the same name. Wrap your own helpers in function_exists().
  • Library minimums rose. The manual lists libcurl 7.61.0, libxml2 2.9.4 and OpenSSL 1.1.1. Very old servers may not meet them.

Scan and fix commands

Run these on a workstation or in CI. Keep them off the web server.

composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev phpcompatibility/php-compatibility:"^10.0.0@dev"
vendor/bin/phpcs -i

# during the move: code must run on both versions
vendor/bin/phpcs -ps --standard=PHPCompatibility --runtime-set testVersion 7.4-8.4 \
  --ignore='*/vendor/*,*/node_modules/*' --report-full=compat-report.txt .

# after cutover: target only 8.4
vendor/bin/phpcs -ps --standard=PHPCompatibility --runtime-set testVersion 8.4 \
  --ignore='*/vendor/*,*/node_modules/*' .

The version constraint above is the one the PHPCompatibility README showed on 22 Oct 2026. Check the README for the current one. PHPCompatibility 10 needs PHP_CodeSniffer 4.0.2 or newer.

Rector can apply some of the fixes. Review its diff before you commit it.

composer require rector/rector --dev
vendor/bin/rector                      # offers to create rector.php
vendor/bin/rector process --dry-run    # preview
vendor/bin/rector process              # apply
<?php
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withPhpSets();   // reads the target PHP version from composer.json

Neither tool replaces staging tests. They read code and never execute it. Leave plugin and theme code you do not own alone and update it instead. On WordPress, see change the PHP version.

Last reviewed on 22 October 2026.