Migrating PHP 7 to PHP 8: Breaking Changes and a Practical Checklist
AI generated
<?php
8.4
PHP · Migration · Breaking Changes · PHP 8.4
Migrating PHP 7 to PHP 8
Understand the breaking changes, control the risk

Anyone still running PHP 7.4 in production is operating a version without security updates on a codebase that barely any modern Composer package still supports. Migrating PHP 7 to PHP 8 is technically manageable once you understand the type system changes, removed functions, and string comparisons, and once you approach it systematically with PHPStan and Rector instead of improvising.

17 min read TypeError · JIT · PHPStan · Rector PHP 7.4 to 8.4

1. Why migrating from PHP 7 to PHP 8.x is urgent

PHP 7.4 reached its official end of life in November 2022. Since then, there have been no more security patches, not even for critical vulnerabilities in the Zend Engine or in core extensions such as OpenSSL bindings or session handling. Anyone who needs to migrate PHP 7 to PHP 8 is not doing it out of curiosity about new language features, but because every additional week on an unsupported version increases the risk of an unfixable security hole. Compliance requirements such as PCI-DSS already assume a supported runtime version for payment processing, and cyber insurance policies increasingly check whether production systems run end-of-life software.

The ecosystem has shifted as well. Current major releases of Symfony, Laravel, and practically every relevant Composer package require at least PHP 8.1, and many already require 8.2 or 8.3. Staying on PHP 7.4 therefore automatically freezes all dependencies at outdated versions and cuts off access to bug fixes that only appear in newer package releases. This coupling between the language version and dependency versions is the reason a PHP migration rarely stays isolated, but almost always triggers a larger dependency update as well.

The third driver is performance. The optimizations to the Zend Engine since PHP 8.0, combined with the JIT compiler and improved memory layouts for arrays and objects, noticeably reduce CPU load and memory consumption without any code changes at all. Under realistic load, benchmarks of pure interpreter optimizations between PHP 7.4 and PHP 8.3 typically show double-digit percentage savings in compute time. For teams that scale or bill server resources based on utilization, that is a direct cost factor that justifies migrating PHP 7 to PHP 8 on economic grounds alone, independent of the security argument.

2. Breaking changes in the type system: TypeError instead of Warning

The most important conceptual break when migrating PHP 7 to PHP 8 concerns how type errors are handled. In PHP 7, many internal functions merely emitted an E_WARNING when given the wrong argument type, then continued with null or false. The script kept running, often producing a silent follow-up error somewhere else entirely. PHP 8 throws a TypeError in the same situations, immediately interrupting the execution path unless it is caught. That is stricter, but more honest: an error becomes visible where it originates instead of propagating unnoticed through the call stack.

In addition, the exception hierarchy was already reworked in PHP 7 so that errors such as division by zero or calls to non-existent methods are thrown as Error objects implementing the shared Throwable interface. PHP 8 builds on this consistently: argument count mismatches, access to undefined constants, and wrong types passed to internal functions now all consistently produce TypeError or ArgumentCountError. Anyone lifting existing code to PHP 8 must therefore expect not only new error messages, but also needs to add targeted try/catch blocks for TypeError in critical code paths where a silent fallback used to be sufficient.

In practice, this shows up most often with string functions called with null. A call like strlen($value), where $value comes back as null from a database query, only produced a deprecation warning in PHP 7.4. Since PHP 8.1, passing null to a non-nullable internal parameter is deprecated, and in many strict contexts this now escalates into a hard error. The example below shows the difference in behavior concretely.


<?php
declare(strict_types=1);

// PHP 7.4 behavior (conceptual): calling with a wrong type on an
// internal function only triggered a warning and returned null/false.
// The script continued running with a silently broken value.
function legacyBehaviorExample(?string $raw): int
{
    // In PHP 7.4 this would emit E_WARNING and return 0 on failure,
    // masking the real bug further down the call stack.
    return (int) $raw;
}

// PHP 8.x behavior: passing an incompatible type into a strictly
// typed function now throws a TypeError instead of warning silently.
function strictBehaviorExample(int $count): string
{
    return str_repeat('*', $count);
}

try {
    // Passing a non-numeric string throws TypeError under strict_types
    echo strictBehaviorExample('not-a-number');
} catch (TypeError $e) {
    // The error surfaces immediately at the call site, not later
    error_log('TypeError caught during migration check: ' . $e->getMessage());
}

// Array access on a non-array value: PHP 7.4 warned and returned null,
// PHP 8 still returns null for simple offset access, but function calls
// on that value now throw instead of silently returning false/null.
$config = null;
$timeout = $config['timeout'] ?? 30; // still safe with null coalescing

3. Named arguments, constructor promotion, and the match expression as new possibilities

Alongside the breaking changes, PHP 8 also brings language features that make code more readable while you migrate PHP 7 to PHP 8. Named arguments let you address function parameters by name rather than position when calling them. That is especially valuable for functions with many optional parameters, because you only need to specify the values you actually care about, without repeating earlier parameters just to reach their default values. In PHP 7, you either had to pass an options array or explicitly write out every intermediate parameter with its default value.

Constructor property promotion drastically reduces the typical boilerplate of value objects and data classes. Instead of writing the property declaration, the constructor parameter, and the assignment in the constructor body separately, you declare the property directly in the constructor's parameter list. In a typical domain object with five to ten properties, this saves between 30 and 50 lines of code without changing behavior at all. The match expression, finally, replaces many switch constructs with an expression form that uses strict comparison (=== instead of ==) and has no fallthrough risk, eliminating an entire class of classic switch bugs.


<?php
declare(strict_types=1);

// PHP 7.4 style: verbose constructor, positional arguments, switch statement
final class LegacyOrderPhp7
{
    private string $status;
    private int $priority;
    private bool $express;

    public function __construct(string $status, int $priority, bool $express)
    {
        $this->status = $status;
        $this->priority = $priority;
        $this->express = $express;
    }

    public function shippingLabel(): string
    {
        switch ($this->status) {
            case 'pending':
                $label = 'Waiting for payment';
                break;
            case 'paid':
                $label = 'Ready to ship';
                break;
            case 'shipped':
                $label = 'In transit';
                break;
            default:
                $label = 'Unknown';
        }
        return $label;
    }
}

$legacy = new LegacyOrderPhp7(status: 'paid', priority: 1, express: true);

// PHP 8.x style: constructor property promotion, named arguments, match
final class ModernOrderPhp8
{
    public function __construct(
        private readonly string $status,
        private readonly int $priority = 1,
        private readonly bool $express = false,
    ) {
    }

    public function shippingLabel(): string
    {
        // match is an expression, uses strict comparison, no fallthrough
        return match ($this->status) {
            'pending' => 'Waiting for payment',
            'paid' => 'Ready to ship',
            'shipped' => 'In transit',
            default => 'Unknown',
        };
    }
}

// Named arguments: only override what differs from the default
$modern = new ModernOrderPhp8(status: 'paid', express: true);

4. Numeric string comparisons and new string functions

One of the most subtle, yet most consequential, changes when migrating PHP 7 to PHP 8 concerns the loose comparison (==) between numbers and non-numeric strings. In PHP 7, an expression like 0 == "foo" first converted the string to a number, which yielded 0, so the comparison evaluated to true. In practice this regularly caused security vulnerabilities, for example when password hashes in the format "0e123..." (so-called magic hashes) were incorrectly interpreted as numerically equal to 0. Since PHP 8.0, when comparing a number to a string, the number is converted to a string and compared as a string if the string is not numeric. That means 0 == "foo" correctly evaluates to false in PHP 8.

This change affects a surprising number of code paths that relied on implicit type juggling, such as comparing database IDs that arrive as strings from an API against integer constants. Anyone who systematically searches for == comparisons with mixed types during a PHP migration and switches them to === or explicit type casts eliminates an entire class of bugs permanently, regardless of PHP's actual runtime behavior.

Alongside this, PHP 8 natively ships str_contains(), str_starts_with(), and str_ends_with(), three long-missing string functions. They replace the error-prone pattern strpos($haystack, $needle) !== false, where a forgotten strict comparison (!== instead of !=) causes a match at position 0 to be incorrectly treated as "not found", because 0 == false evaluated to true in PHP 7. The new functions return a genuine boolean and make this class of bug structurally impossible.


<?php
declare(strict_types=1);

// Numeric string comparison: PHP 7 vs PHP 8 behavior
var_dump(0 == 'foo');    // PHP 7.4: true (string cast to 0)  | PHP 8.x: false
var_dump('1' == '01');   // both versions: true (both numeric strings)
var_dump('10' == '1e1'); // both versions: true (both numeric strings)
var_dump(100 == '1e2');  // both versions: true (numeric string, compared as number)

// The classic PHP 7 security trap: "magic hash" style comparisons
$storedHash = '0e123456789';
$userInput = '0';
// PHP 7.4: (0 == "0e123456789") could evaluate to true due to scientific
// notation parsing, a known source of authentication bypass bugs.
// PHP 8.x: non-numeric strings are never silently cast to 0.

// WRONG (PHP 7 era pattern): fragile strpos check
function containsNeedleLegacy(string $haystack, string $needle): bool
{
    // Bug-prone: a match at position 0 requires !== false, easy to get wrong
    return strpos($haystack, $needle) !== false;
}

// RIGHT (PHP 8): explicit, readable, no off-by-zero trap possible
function containsNeedleModern(string $haystack, string $needle): bool
{
    return str_contains($haystack, $needle);
}

$path = '/var/www/html/index.php';
var_dump(str_starts_with($path, '/var/www'));  // true
var_dump(str_ends_with($path, '.php'));        // true
var_dump(str_contains($path, 'html'));         // true

5. Removed and deprecated features: create_function(), each(), and more

PHP 8.0 cleans up a range of functions that were already deprecated in PHP 7 but could still be called. create_function(), which dynamically generated function code from a string via eval(), was completely removed. It was a security risk to begin with, since it mixed string concatenation with code execution, and has been replaced by closures and arrow functions for years. Also removed was each(), which manually advanced the internal array pointer and returned key/value tuples one pair at a time, a pattern fully covered by foreach and with no place in modern code.

Curly-brace access to string offsets ($string{0} instead of $string[0]) was also removed after being deprecated since PHP 7.4. Anyone who still has this syntax in their code, usually inherited from a very old codebase, must switch to square brackets before jumping to PHP 8, since the parser now throws a fatal error rather than a warning. Another often-overlooked breaking change concerns sort stability: since PHP 8.0, all sorting functions such as sort(), usort(), and asort() are stable, meaning elements with equal comparison values keep their relative order. In PHP 7, the order of equal elements was implementation-dependent and could differ between PHP patch releases.

For your PHP migration, this means an automated search for create_function(, each(, and the curly-brace array access pattern across the entire repository is a mandatory step before any upgrade attempt, since all three patterns fail with a fatal error rather than a warning as soon as the interpreter runs on PHP 8.


<?php
declare(strict_types=1);

// REMOVED in PHP 8.0: create_function() no longer exists at all
// $callback = create_function('$a, $b', 'return $a + $b;');
// Fatal error: Uncaught Error: Call to undefined function create_function()

// Modern replacement: arrow function or closure
$callback = fn(int $a, int $b): int => $a + $b;
echo $callback(2, 3); // 5

// REMOVED in PHP 8.0: each() no longer exists
// while (list($key, $value) = each($array)) { ... }
// Fatal error: Uncaught Error: Call to undefined function each()

// Modern replacement: foreach handles the same task safely
$array = ['id' => 42, 'name' => 'Widget', 'active' => true];
foreach ($array as $key => $value) {
    // process each key/value pair without manual pointer management
    echo sprintf("%s => %s\n", $key, var_export($value, true));
}

// REMOVED in PHP 8.0: curly brace string offset access
// $first = $someString{0};
// Fatal error: Uncaught Error: Cannot use '{}' for indexing

// Modern replacement: square bracket offset access
$someString = 'PHP 8 migration';
$first = $someString[0]; // 'P'

6. The JIT compiler: performance differences evaluated realistically

The JIT (Just-In-Time) compiler available since PHP 8.0 translates frequently executed opcode paths into native machine code at runtime, bypassing the classic interpreter overhead. Marketing material often presents the JIT as a blanket performance miracle, but in practice its benefit depends entirely on the workload. For CPU-bound tasks such as mathematical computations, image processing, compression algorithms, or parsing very large data structures, the JIT shows measurable runtime gains, in some benchmarks in the range of 20 to 40 percent compared to pure interpreter execution.

For a typical, database-heavy web application, the kind most shop and CMS systems represent, the effect is small to barely measurable. The reason is that the execution time of an HTTP request consists mostly of I/O wait: database queries, external API calls, filesystem access, and network latency dominate the total time, while the actual PHP code the JIT could speed up makes up only a small fraction of the request. A JIT-compiled loop that takes two microseconds instead of five microseconds disappears entirely next to a 50-millisecond database query.

For your PHP migration, this means the JIT is not an argument you should cite in isolation for the upgrade if the application is predominantly I/O-bound. It makes sense to enable and test the JIT in tracing mode with opcache.jit=1255, but calibrate expectations against the actual workload profile rather than against blanket benchmark figures from compute-intensive microbenchmarks that have little to do with your own use case.

7. Practical checklist before the migration: Composer, PHPStan, and Rector

Before a team begins the actual PHP migration, a systematic run-up pays off, rather than simply swapping the PHP version on the server and watching what breaks. The first step is checking all Composer dependencies for PHP 8 compatibility. Running composer outdated --direct and looking at the require section of each package clarifies which libraries already offer PHP 8-compatible major versions and which need a larger update before the jump is even possible.

The second step is static analysis with PHPStan. A level 5 or level 6 run against the existing codebase surfaces type inconsistencies, potential TypeError sources, and dead code paths long before a user hits an error in production. PHPStan rule sets for PHP version compatibility, such as phpstan/phpstan-deprecation-rules, additionally flag every use of a deprecated or removed function explicitly in the report.

The third step is automated refactoring with Rector. Rector ships ready-made rule sets for every PHP version jump, such as Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_81, which automatically convert patterns like missing property types, outdated array function calls, or nullable parameters to the modern style. The --dry-run mode shows a full diff before any change is applied, so the team can review the proposed transformations before they take effect.


#!/usr/bin/env bash
# Migration pipeline: dependency check, static analysis, automated refactoring
set -euo pipefail

# 1. Check which Composer dependencies are not yet PHP 8 compatible
composer outdated --direct --format=json > outdated-report.json

# 2. Install PHPStan with deprecation rules for the migration audit
composer require --dev phpstan/phpstan phpstan/phpstan-deprecation-rules

# Run static analysis to surface TypeError risks and deprecated calls
vendor/bin/phpstan analyse src --level=6 --error-format=table

# 3. Install Rector and run the PHP 8.1 upgrade set in dry-run mode first
composer require --dev rector/rector

# Dry-run shows a full diff of proposed changes without touching files
vendor/bin/rector process src --dry-run --config=rector.php

# Once the diff has been reviewed, apply the changes for real
vendor/bin/rector process src --config=rector.php

# Re-run PHPStan after Rector to confirm no new type errors were introduced
vendor/bin/phpstan analyse src --level=6

8. Test strategy during the migration: test suite, feature flags, and canary deployments

A solid test suite is the prerequisite for a PHP migration not becoming a matter of blind trust. Anyone with no unit test coverage, or only thin coverage, should backfill tests for the most critical business logic paths before the actual version switch, especially for areas affected by the breaking changes described in this article: loose type comparisons, string functions, and sort logic. Ideally these tests run against both the old and the new PHP version in the CI pipeline in parallel, so behavioral differences become visible immediately instead of surfacing in production.

Feature flags help break the migration down into controllable units. Instead of switching the entire application to PHP 8 on a single cutover date, you can split the application server pool: some instances run PHP 8.x on a trial basis while most of the traffic still stays on PHP 7.4. If critical errors appear, the traffic share on the new version can be reduced to zero immediately without performing a full rollback of the codebase.

This canary deployment pattern, combined with close error monitoring through tools such as Sentry or New Relic, reveals whether the new PHP version generates additional exceptions in production, particularly TypeError and ArgumentCountError, that did not show up in staging. Only once the error rate on the canary group stays stable over a defined period does the traffic share get increased gradually, until the entire fleet runs on the new version.

9. Migrating step by step: from 7.4 through 8.0, 8.1, 8.2, 8.3 to 8.4

Jumping directly from PHP 7.4 to PHP 8.4 in a single step is theoretically possible, but considerably riskier in practice than a step-by-step PHP migration through the intermediate versions. Every minor version between 8.0 and 8.4 carries its own breaking changes and deprecations, and in a direct jump they all manifest simultaneously, making them nearly impossible to disentangle during debugging. A bug caused by the sort stability introduced in 8.0 is hard to distinguish, in a big-bang upgrade to 8.4, from a bug caused by the changed null-parameter handling in 8.1.

The recommended path goes through 8.0 as the first intermediate step, because that is where the biggest structural changes live: union types, the new type system behavior, the numeric string comparisons, and the removed functions. Once that step is stable in production, 8.1 follows with enums, readonly properties, and the deprecation of implicit nullable parameters. Then 8.2 with readonly classes and further deprecations, 8.3 with typed class constants, and finally 8.4 with property hooks and asymmetric visibility. Each of these steps can be validated individually in staging, with a manageable diff of breaking changes.

For teams with limited capacity, a pragmatic compromise is to schedule at least two intermediate stops, for example 7.4 to 8.1 and then 8.1 to 8.4, rather than running four or five individual steps. In any case, it is important that PHPStan and Rector run again at every intermediate step, since new deprecations in the next respective version only become visible through the updated rule sets.

Feature PHP 7.4 PHP 8.0 PHP 8.4
Named Arguments not available available available
Constructor Property Promotion not available available available
Match Expression not available available available
Enums not available not available available (since 8.1)
Readonly Properties not available not available available (since 8.1)
JIT Compiler not available available available, tuned
Nullsafe Operator not available available available
str_contains() not available available available

10. Summary

Migrating from PHP 7 to PHP 8.x is not a purely technical detail update, but a combination of security necessity, ecosystem pressure, and a manageable, yet serious, list of breaking changes. Anyone planning to migrate PHP 7 to PHP 8 should treat the type system behavior, numeric string comparisons, removed functions such as create_function() and each(), and sort stability as concrete, checkable items on a checklist, not as an abstract footnote.

With PHPStan for static analysis, Rector for automated refactoring, a solid test suite, and a step-by-step version strategy through 8.0, 8.1, 8.2, and 8.3, the risk of a PHP migration can be reduced to a manageable level. The effort is minor compared to the costs that running production on an unsupported, security-vulnerable PHP 7 instance causes in the long run.

Migrating PHP 7 to PHP 8, the essentials at a glance

Type System

Internal functions now throw TypeError instead of merely warning. Add try/catch blocks in critical code paths.

String Comparisons

0 == "foo" is false since PHP 8. Audit every mixed == comparison against ===.

Removed Functions

create_function(), each(), and curly-brace array access no longer exist. Search for them ahead of time.

Tooling

PHPStan for static analysis, Rector for automated refactoring, migrate step by step through 8.0 to 8.4.

11. FAQ: Migrating PHP 7 to PHP 8

1How long has PHP 7.4 been without security updates?
PHP 7.4 reached end of life in November 2022. Since then there have been no official security patches, not even for critical vulnerabilities in the Zend Engine or core extensions.
2Most important type system difference?
PHP 8 throws TypeError for wrong argument types, PHP 7 only warned and continued with null or false. Errors become visible immediately instead of propagating silently.
3Why is 0 == "foo" now false instead of true?
PHP 7 cast the string to a number (0). PHP 8 instead casts the number to a string when the string is not numeric, and compares as strings.
4Jump directly from 7.4 to 8.4?
Technically possible, but risky. Every minor version brings its own breaking changes that overlap. A step-by-step path through 8.0, 8.1, 8.2, 8.3 is recommended.
5Which functions were removed?
create_function(), each(), and curly-brace array access. All three were deprecated since PHP 7.4 and now produce a fatal error instead of a warning.
6Does the JIT always help performance?
No. CPU-bound workloads see measurable gains, but I/O-heavy web applications are dominated by wait time, so the JIT effect is barely noticeable there.
7How does PHPStan help concretely?
PHPStan surfaces type inconsistencies, potential TypeError sources, and deprecated function calls before the code ever runs on PHP 8.
8What does Rector do during migration?
Rector applies rule sets per version automatically, such as adding missing property types. --dry-run shows a full diff for review beforehand.
9What is a sensible canary deployment?
Some instances run on the new version, most traffic stays on the old one. If errors occur, the new share can be reduced to zero immediately.
10Do Composer dependencies need updating first?
Usually yes. Current major versions of Symfony, Laravel, and most packages require at least PHP 8.1. composer outdated --direct shows the need.

Mironsoft

PHP development, legacy modernization, and Magento agency

Still running PHP 7 and unsure where the risks are?

We analyze your codebase with PHPStan and Rector, identify breaking changes before the switch, and guide the migration step by step to a production-ready PHP 8.4 environment, including Magento-specific adjustments.

Compatibility Audit

PHPStan analysis and Composer dependency check before the version switch

Automated Refactoring

Rector rule sets per version jump, with a review diff before every application

Guided Rollout

Canary deployments, monitoring, and a step-by-step switch without production downtime