The go-to resource for learning PHP, Laravel, Symfony, and your dependencies.

Common Breaking Changes in PHP 8.x Upgrades


The Migration Path

In the Serengeti, elephant herds remember water sources across hundreds of miles, knowledge passed from matriarch to younger members over decades. When drought comes, this accumulated memory means survival—the herd knows which distant waterholes remain when others dry up. Those that ignore the changed landscape, attempting to follow old paths to vanished resources, face tragedy.

Similarly, when we upgrade from PHP 7.x to PHP 8.x, we need accumulated knowledge about which patterns remain viable and which have “dried up”—deprecated functions, changed behaviors, breaking syntax. The language itself has evolved its philosophy: where PHP 7.x tolerated certain patterns for flexibility’s sake, PHP 8.x enforces stricter rules for the sake of type safety and reliability. This article serves as that accumulated memory, showing you which paths lead to successful migration and which lead to the dead ends of runtime errors and production failures.


Why These Changes Matter

Before we examine specific breaking changes, let’s step back and ask: why did the PHP core team make these changes in the first place? The answer lies in two competing pressures that have shaped PHP’s evolution.

First, PHP is used to power a significant portion of the web—by some estimates, over 75% of all websites use PHP in some form. This legacy creates responsibility; the PHP team must consider the impact on millions of existing codebases. Second, modern development practices demand better tooling and type safety. Developers working on large applications expect their language to catch errors early, not at runtime.

PHP 8.x navigates this tension by introducing stricter checks that catch errors sooner, while providing migration tools to ease the transition. Most of these changes follow a consistent pattern: behaviors that previously issued warnings now throw exceptions, and ambiguous type handling is made explicit. For you as a developer, this means you’ll catch more bugs during development rather than in production—a worthwhile trade-off for most teams.

Of course, the upgrade process requires effort. Let’s examine the changes by PHP version so you can plan your migration effectively.


Prerequisites

Before you begin your PHP 8.x upgrade journey, ensure you have:

  • Current PHP version knowledge: Know exactly which PHP version you’re running (check with php -v). You’ll need this baseline to understand which changes apply.
  • Composer installed: You’ll use Composer to install static analysis tools. Ensure you can run composer commands successfully.
  • Test suite: While not strictly required, having automated tests dramatically reduces upgrade risk. If you lack test coverage, consider writing smoke tests for critical functionality before upgrading.
  • Staging environment: You should have a staging environment that mirrors production as closely as possible. We’ll test there before any production deployment.
  • Backup strategy: Ensure you can roll back if needed. At minimum, commit all code to version control and have a database backup plan.

If you’re missing any of these, we recommend addressing them first. They’ll make your upgrade safer and more predictable.


Key Breaking Changes in PHP 8.0

PHP 8.0 was a watershed release, introducing the JIT compiler and numerous type system improvements. For existing applications, the following changes are the most frequent sources of incompatibility.

Stricter Type Checks for Arithmetic and Bitwise Operators

In PHP 7.x, arithmetic and bitwise operations with non-numeric operands would typically issue a warning but continue executing. PHP 8.0 changes this behavior to throw a TypeError instead. This makes sense: what numeric meaning could '5' + [] possibly have? The old behavior produced unpredictable results; the new behavior fails fast.

PHP 7.x behavior (warning, then execution):

// PHP 7.x
$result = '5' + []; // Warning: A non-numeric value encountered in file on line X
// $result would be 5 due to silent conversion of '5' to 5 and [] to 0

PHP 8.0+ behavior (TypeError thrown):

// PHP 8.0+
$result = '5' + []; // TypeError: Unsupported operand types: string + array

This change protects you from subtle bugs where data comes from user input or external sources. If you’re processing form submissions or API responses, you now get immediate feedback when values aren’t in the expected format.

Practical approach: Validate your data before operations, or ensure your variables contain numeric types:

// Validating before operations
$value = $_GET['count'] ?? 0;
if (is_numeric($value)) {
    $result = (int)$value + 10;
} else {
    // Handle invalid input appropriately
    throw new InvalidArgumentException('Expected numeric value');
}

// Or, use type declarations to enforce consistency
function add(int $a, int $b): int {
    return $a + $b;
}

One may wonder: why not keep the warning and continue? The answer is that silent failures are harder to debug than explicit errors. If a calculation produces an unexpected result due to type juggling, you might not notice for weeks—until the financial reports don’t balance. Failing fast during development or testing surfaces these issues immediately.

Named Arguments and Reserved Keywords

PHP 8.0 introduced named arguments—a feature that lets you pass function arguments by name rather than position. This improves readability, especially for functions with many optional parameters. However, named arguments create a subtle compatibility issue: any class, interface, trait, or function named with a reserved keyword that could be used as a parameter name will cause problems if someone tries to use named arguments with it.

The trade-off here is clear: the PHP team gained a powerful new feature for modern code at the cost of breaking compatibility with the uncommon but valid pattern of using keywords as identifiers. For the vast majority of applications, the readability gains from named arguments far outweigh the refactoring required to rename a handful of classes or functions. This exemplifies PHP 8.x’s broader philosophy—prioritizing the long-term health of the language and modern development experience over preserving every legacy pattern.

The match expression and enum keyword (in PHP 8.1) are notable examples. If your codebase contains a class match or function enum(), those names will conflict with the new reserved keywords. Similarly, if you have a class named array and someone uses new array() as a named argument, PHP 8.0 will interpret it differently than PHP 7.x.

PHP 7.x (class named ‘array’ works fine):

// PHP 7.x - identifier names can match keywords without issue
class array {
    // This class name parses fine in PHP 7.x
    public function process() {
        return 'result';
    }
}

function http_request(string $url, $options = []) {
    return new array();
}

// Normal positional call works as expected
http_request('https://example.com', new array());

PHP 8.0+ (class ‘array’ problematic with named arguments):

// PHP 8.0+ - when using named arguments:
http_request(url: 'https://example.com', options: new array());
// PHP 8.0 interprets 'array' as the built-in type or interface name
// Fatal error: Cannot instantiate interface ArrayAccess

The fix: Rename conflicting classes, functions, or methods. This isn’t optional—your code will fail on PHP 8.0+ if these names conflict with reserved keywords that may be used as named arguments:

// Rename to avoid keyword collision
class CustomArray {
    public function process() {
        return 'result';
    }
}

function http_request(string $url, $options = []) {
    return new CustomArray();
}

You might be wondering: how many applications actually have classes named after keywords? Older codebases sometimes used class array or class match before these became reserved. If you’re migrating a legacy application, scanning for these patterns is worthwhile. The good news is that automated tools like PHPStan and rector can detect and fix many of these issues automatically.

The match Expression Is Now Reserved

Speaking of reserved keywords, match became a reserved keyword in PHP 8.0. Any function, method, class, interface, or trait named match will cause a parse error. This is a straightforward but urgent issue if your code contains such a name.

The trade-off is straightforward: adopting the more expressive match expression (which provides strict comparison and better error handling than switch) requires renaming any existing match identifiers. For most teams, the benefit—a more robust and intention-revealing control structure—outweighs the one-time refactoring cost. Those who delay the rename effectively opt out of using match in any file where the conflict exists.

PHP 7.x (works):

// PHP 7.x - 'match' is just a regular identifier
function match($value) {
    return $value === 'win';
}

PHP 8.0+ (fatal error):

// PHP 8.0+ - 'match' is now a reserved keyword
// Fatal error: Cannot use 'match' as a function name as it is a reserved keyword
function match($value) {}

The migration path here is simple but potentially tedious: rename every occurrence of match as an identifier to something else (e.g., find_match, is_match, or match_value). Search your codebase for function match, class match, etc. Because this is a syntax-level change, PHP won’t even execute the file—you’ll see errors immediately when running your test suite or starting the application.


Key Breaking Changes in PHP 8.1

PHP 8.1 continues the trend of stricter typing while introducing valuable new features like Enums and Fibers. The breaking changes here are fewer but still important.

enum Is Now Reserved

Building on the keyword reservation trend, enum became a reserved keyword in PHP 8.1. If you have any class, interface, trait, or function named enum, you’ll need to rename it. PHP’s new native enum feature is valuable enough to warrant this change, but the migration requires identifying and renaming conflicting identifiers.

The trade-off: native enums provide type-safe, self-documenting enumerations that eliminate many bugs associated with integer or string constants. The cost is minimal for most projects—only those who used enum as an identifier need to refactor. For teams that can’t upgrade a particular class, the alternative is to forgo native enums entirely and continue with custom implementations, missing out on language-level support.

Before (PHP 8.0):

// PHP 8.0 and earlier - 'enum' is just a regular identifier
class enum {
    // Your custom enum implementation
}

After (PHP 8.1+):

// PHP 8.1+ - 'enum' is now a reserved keyword
// Fatal error: Cannot use 'enum' as a class name as it is a reserved keyword
class enum {}

Rename to something like CustomEnum, MyEnum, or whatever fits your domain. Because this is a reserved keyword, the parser fails immediately—no graceful degradation.

Non-Nullable Internal Function Parameters

PHP internal functions (the ones built into the language) now throw a TypeError if you pass null to a parameter that isn’t explicitly declared as accepting null. In PHP 7.x, passing null to such parameters might issue a warning but continue execution, often with unexpected results.

Consider strpos(), which expects a string needle and haystack. Passing null never makes sense—what position would you expect strpos('example', null) to return?

PHP 7.x (warning but continues):

// PHP 7.x
$position = strpos('example', null);
// Warning: strpos(): Non-string needle in /path/to/file.php on line X
// $position returns false

PHP 8.1+ (TypeError):

// PHP 8.1+
$position = strpos('example', null);
// TypeError: strpos(): Argument #2 ($needle) must be of type string, null given

The practical implication: audit your code for places where null could flow into internal function parameters. Common culprits include:

  • Uninitialized variables
  • Array access with missing keys
  • Function return values that might be null
  • $_GET/$_POST superglobals without null coalescing

Mitigation strategies:

// PHP 8.1+ - approaches to avoid TypeError with internal functions

// Strategy 1: Use null coalescing to provide defaults
$search = $_GET['search'] ?? '';  // Guarantees $search is a string
$position = strpos('example', $search); // Works: no TypeError

// Strategy 2: Validate before calling
$value = getValueFromSomewhere(); // Could return null
if ($value !== null) {
    $position = strpos('example', $value); // Safe
} else {
    // Handle the null case appropriately
    throw new InvalidArgumentException('Expected non-null value');
}

// Strategy 3: Use type declarations to catch issues at call sites
function findPosition(string $haystack, string $needle): int|false {
    // PHP 8.0+: union return type for false
    // The type hints ensure callers pass strings, preventing TypeError
    return strpos($haystack, $needle);
}

One may wonder: does this change affect all internal functions? Most, but not all—some internal functions do accept null where it makes sense. Check the documentation for each function. The PHP team made this change intentionally to surface bugs earlier; in practice, passing null to an internal function that doesn’t expect it indicates a bug in your code logic.

The trade-off is clear: you gain immediate, unmistakable failure at the point of error instead of silent misbehavior or delayed failures. Though this means more upfront work to handle nulls properly, it prevents subtle bugs from propagating through your system and causing incorrect results later.


Key Breaking Changes in PHP 8.2

PHP 8.2 focuses on clean-up and deprecations, preparing the ground for future releases while eliminating problematic patterns.

Deprecation of Dynamic Properties

Creating properties on an object that aren’t declared in the class definition is now deprecated. This pattern—sometimes called “object hydration” or “property overloading” without explicit __set()—was always controversial. Some frameworks (notably Laravel’s Eloquent) rely heavily on this behavior for model attributes.

PHP 7.x (accepted):

class User {
    public $name;
}

$user = new User();
$user->name = 'Alice'; // Declared property: OK
$user->email = 'alice@example.com'; // Dynamic property: OK in PHP 7.x

PHP 8.2+ (deprecation notice):

// Deprecated: Creation of dynamic property User::$email is deprecated
$user->email = 'alice@example.com';

You have three migration paths:

  1. Declare all expected properties in the class definition (recommended):

    class User {
        public $name;
        public $email; // Explicitly declared
    }
  2. Use the #[\AllowDynamicProperties] attribute if you truly need dynamic properties (e.g., for frameworks that rely on this pattern):

    #[\AllowDynamicProperties]
    class User {
        public $name;
    }
    
    $user = new User();
    $user->email = 'alice@example.com'; // No deprecation

    Of course, using this attribute should be a conscious decision; it signals that your class intentionally allows dynamic properties.

  3. Implement __get() and __set() magic methods for controlled property access:

    class User {
        private $data = [];
        
        public function __get($name) {
            return $this->data[$name] ?? null;
        }
        
        public function __set($name, $value) {
            $this->data[$name] = $value;
        }
    }

The trade-off here involves balancing the convenience of dynamic properties (which enables fast prototyping and flexible data structures) against the safety and clarity of explicit property declarations. While frameworks like Laravel have relied on dynamic properties for Eloquent models, the move toward explicit properties ultimately improves IDE support, static analysis, and reduces bugs from typos. Teams using such frameworks should check their framework version for native PHP 8.2 support, which likely already handles this internally—either by using #[\AllowDynamicProperties] selectively or by declaring properties dynamically through code generation.

This deprecation affects many popular frameworks. If you’re using Laravel, Symfony, or similar, check their documentation for PHP 8.2 compatibility—they’ve likely already addressed this internally or provide guidance. The long-term benefit is clearer class interfaces and better static analysis; you and your team will know exactly what properties an object has without reading through magic methods.


Estimating Your Migration Effort

Before we discuss tools, let’s address a practical question: how much work is this upgrade, really? That depends heavily on your codebase.

A small application with 5,000 lines of code and straightforward logic might encounter only a few issues—perhaps some dynamic properties or null handling. A large legacy application with 200,000 lines and little test coverage could uncover dozens or hundreds of incompatibilities.

As a rough guide:

  • Well-maintained code with good test coverage: Days to a week of focused work
  • Moderately maintained code with some tests: One to three weeks
  • Legacy code with minimal tests: Several weeks to months

These are general estimates; your mileage will vary. The key factors are test coverage, code age, and how closely you’ve followed PHP’s evolution. If you’ve kept up with minor releases (7.2 → 7.3 → 7.4), the jump to PHP 8.x is smaller than if you’re still on PHP 7.0.


Tools to Automate Your Upgrade

Manually finding all these issues can be tedious, and it’s easy to miss subtle cases. Thankfully, modern static analysis tools can do the heavy lifting for you.

PHPStan

PHPStan analyzes your code without running it and can detect a wide range of issues, including many PHP 8.x compatibility problems. It understands PHP’s type system deeply and can spot places where strict typing will break your code.

Typical usage:

# Install PHPStan
composer require --dev phpstan/phpstan

# Run analysis at the highest level
vendor/bin/phpstan analyse src --level=max

PHPStan operates in levels from 0 (basic checks) to 9 or max (most thorough). Running at a high level requires good type hints; you may need to add docblocks first.

Psalm

Psalm is another static analyzer with similar capabilities to PHPStan. It’s particularly good at finding security vulnerabilities and dead code. Choose based on your preference; both support PHP 8.x migration checks.

Rector

Rector not only finds issues but can automatically refactor your code to be compatible with newer PHP versions. This is invaluable for large-scale upgrades where manual changes would take too long.

Example: Rector rule to fix dynamic properties:

# Install Rector
composer require --dev rector/rector

# Configure rector.php with PHP 8.2 upgrade rules
# Then run:
vendor/bin/rector process src --set php82

Rector can handle many common breaking changes automatically: dynamic properties, directive property types, union types, and more. That said, you should review its changes carefully—automated refactoring isn’t perfect, and some transformations require human judgment.

Of course, no tool is perfect. Even after automated fixes, run your test suite thoroughly and check edge cases. The tools help enormously, but they can’t understand your business logic.


Verification and Testing

Once you’ve made changes to address breaking changes, we need to verify everything works correctly. Skipping thorough verification risks missing subtle regressions that could surface in production.

Running Your Test Suite

First, run your existing test suite against the upgraded PHP version. We recommend:

# If using PHPUnit
vendor/bin/phpunit

# If using Pest
vendor/bin/pest

# If using Codeception
vendor/bin/codecept run

Pay attention to any failures. Each failure represents a potential breaking change we haven’t addressed yet. If you lack tests, now is the time to write at least smoke tests for critical paths.

PHPStan or Psalm Analysis

Run your static analysis tool at the maximum level to catch remaining compatibility issues:

# PHPStan at maximum level
vendor/bin/phpstan analyse src --level=max

# Psalm with thorough checks
vendor/bin/psalm --php-version=8.2

These tools can find issues your tests might miss—especially around type safety and edge cases.

Manual Spot Checks

Beyond automated tests, manually verify areas of your code that interact with:

  • Type juggling scenarios
  • User input handling
  • External API calls
  • Database queries
  • File operations

For each manual check, verify both the happy path and edge cases. For example, if you fixed arithmetic operations, test with non-numeric inputs to confirm errors are handled gracefully.

Staging Environment Validation

Before deploying to production, we strongly recommend:

  1. Deploy to your staging environment with the upgraded PHP version
  2. Run your full test suite there as well
  3. Perform smoke tests of critical user flows
  4. Load test if your application handles significant traffic

Staging often reveals environment-specific issues: missing extensions, configuration differences, or performance characteristics that your local machine doesn’t show.

Production Monitoring

Even with thorough staging validation, monitor production closely after deployment:

  • Watch error logs for new TypeError or deprecation warnings
  • Monitor application performance (JIT compiler behavior can differ)
  • Set up alerts for increased error rates
  • Have a rollback plan ready

If you encounter issues in production, you’ll need to address them quickly or roll back to PHP 7.x while you fix them.


Long-Term Maintenance Considerations

Upgrading to PHP 8.x is a worthwhile investment that pays dividends in performance and developer productivity. While the breaking changes can seem daunting, most are logical steps toward a stricter, more predictable language.

Think about it this way: each strictness improvement is a guardrail that prevents entire categories of bugs. A TypeError thrown during development is far better than a subtle data corruption discovered in production. The migration effort is a one-time cost; the benefits accrue over years of maintenance.

For most teams, the strategy is:

  1. Upgrade to the latest PHP 7.4 if you’re not already there—this gives you the most recent bug fixes and a smaller jump to PHP 8.
  2. Run static analysis (PHPStan or Psalm) to identify compatibility issues.
  3. Fix issues incrementally, starting with the easiest (renaming reserved keyword conflicts) and moving to more complex (null handling, dynamic properties).
  4. Test thoroughly across your entire test suite.
  5. Staging environment validation with real traffic patterns before production.
  6. Monitor production closely after deployment for any missed edge cases.

One may wonder: should we upgrade directly to the latest PHP 8.x, or go version by version (8.0 → 8.1 → 8.2)? The answer depends on your starting point. If you’re on PHP 7.4, you can upgrade directly to PHP 8.1 or 8.2—there’s no requirement to go through each intermediate version. However, be sure to review the breaking changes for every version between your current version and your target, as they’re cumulative.


Troubleshooting Common Issues

Even with careful preparation, you might encounter specific problems during your upgrade. Here are solutions to frequent issues:

“Cannot use ‘match’ as a function/class name” Parse Error

Symptom: PHP fails to parse your file with a message about match being a reserved keyword.

Solution: You have a function, class, interface, or trait named match. Rename it to something else, like findMatch, isMatch, or matchValue. Search your codebase thoroughly:

grep -r "function match" src/
grep -r "class match" src/

“Cannot use ‘enum’ as a class name” Parse Error

Symptom: PHP fails to parse with a message about enum being reserved.

Solution: Similar to match, you have an identifier named enum. Rename it to CustomEnum, MyEnum, or another domain-appropriate name.

TypeErrors on Internal Function Calls

Symptom: TypeError: strpos(): Argument #2 ($needle) must be of type string, null given or similar for other internal functions.

Solution: Track down where null values are being passed. Common causes:

  • Uninitialized variables: $value never assigned before use
  • Array access with missing keys: $search = $options['search'] when ‘search’ key doesn’t exist
  • Functions returning null unexpectedly

Use debug_backtrace() or Xdebug to find the call site, then add null coalescing or validation:

$search = $options['search'] ?? ''; // Provide default
// or
if ($value !== null) { /* use $value */ }

Deprecation: Creation of Dynamic Property

Symptom: “Deprecated: Creation of dynamic property X is deprecated” in PHP 8.2.

Solution: You’re setting a property not declared in the class. Either:

  1. Declare the property in the class definition (recommended)
  2. Add #[\AllowDynamicProperties] attribute if you truly need dynamic properties
  3. Implement __get() and __set() to handle dynamic access

If you’re using a framework like Laravel, check if you’re on a PHP 8.2-compatible version. Framework maintainers may have already addressed this.

Performance Degradation After Upgrade

Symptom: Your application runs slower on PHP 8.x than PHP 7.x.

Solution: First, confirm you have OPcache enabled, as it’s crucial for PHP 8.x performance. Then:

  • Benchmark with php -d opcache.enable_cli=0 benchmark.php to isolate PHP performance
  • Check for code that heavily relies on features that changed optimization characteristics
  • Consider JIT compiler settings in php.ini (opcache.jit and opcache.jit_buffer_size)

Though the JIT compiler exists, it helps mostly with CPU-intensive workloads, not typical web requests. Most web applications see similar or better performance without JIT.

Composer Dependency Conflicts

Symptom: Composer fails with “Conflict” or “Could not find a version” errors after upgrading PHP.

Solution: Your dependencies may not support your target PHP version yet. Check each package’s composer.json for php constraints. You have options:

  1. Find newer, compatible versions of your dependencies
  2. Replace incompatible packages with alternatives
  3. Stay on an older PHP version longer (but plan to upgrade eventually)
  4. Contribute patches to upstream packages

Use composer why-not php 8.1 to see which packages block the upgrade.

Test Suite Fails with “Cannot redeclare class/function”

Symptom: Tests fail with “Cannot redeclare class” errors after upgrade.

Solution: This often indicates code that worked in PHP 7.x due to case-insensitive class/function names. PHP 8.x handles these more strictly. Check for:

  • Two classes with the same name but different cases
  • Conditional class definitions (class declared inside if statements)
  • Files included multiple times without require_once protection

Refactor to ensure unique, consistently-cased class names and use proper autoloading.


These are the most frequently encountered issues. If you run into something not covered here, consult the PHP migration guides at php.net or community resources like Stack Overflow with specific error messages.


What Are Your Challenges?

Upgrading PHP versions is one of the most impactful maintenance tasks you can undertake. The breaking changes represent PHP’s evolution toward a more robust, type-safe language that serves the needs of modern applications.

What are your biggest challenges when upgrading PHP? Have you encountered breaking changes not covered here? Share your experiences—the community learns from each other’s migration journeys.

Sponsored by Durable Programming

Need help with your PHP application? Durable Programming specializes in maintaining, upgrading, and securing PHP applications.

Hire Durable Programming