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

Upgrading PHP While Maintaining Backward Compatibility



In 1880, the U.S. Census faced a crisis. The previous decennial census had taken eight years to complete—and the next one was unlikely to be faster. With population growth and increasing data complexity, manual tabulation was becoming untenable. The solution came in the form of the tabulation machine, an electromechanical device that automated census data processing.

The tabulation machine didn’t enable workers to do anything they couldn’t do manually—it simply let them do it faster, more accurately, and at scale. It automated the tedious, error-prone parts of the work while leaving human judgment intact.

Similarly, upgrading a PHP application from an end-of-life version presents a familiar challenge: modernization without disruption. The code itself is often sound—it works, it’s tested, it serves users. But staying on unsupported PHP versions introduces security risks and performance limitations. The task is not to rewrite from scratch, but to systematically transform the existing codebase while keeping it functional throughout.

This guide provides a pragmatic approach to upgrading PHP while maintaining backward compatibility—using automation tools like Rector and PHPStan, combined with a phased strategy that minimizes risk. Of course, every application has unique characteristics; the specific tools and timeline will vary. Nevertheless, the principles outlined here apply broadly to most PHP applications.

Why Upgrade PHP?

Before diving into the “how,” let’s examine the reasons for upgrading. Continuing to run an outdated PHP version presents three significant concerns:

  1. Security vulnerabilities that are no longer patched for unsupported versions. The PHP team provides security fixes only for actively supported versions; running an end-of-life version means you must backport fixes yourself or accept the risk.

  2. Performance limitations that impact user experience and infrastructure costs. Newer PHP versions typically offer substantial performance improvements — PHP 8.0 was approximately 30% faster than PHP 7.4 in many benchmarks, and subsequent versions have continued this trend.

  3. Missing language features that improve code quality and developer productivity. Union types (PHP 8.0), enums (PHP 8.1), and the JIT compiler (PHP 8.0) represent significant quality-of-life improvements that affect both correctness and performance.

These benefits must be weighed against the upgrade effort itself — testing, refactoring, and potential downtime. A disciplined approach, as outlined here, manages those costs effectively.

Of course, the specific impact varies by application. A high-traffic e-commerce site faces different stakes than an internal tool with limited users. That said, security considerations alone make running unsupported versions problematic for any production system. This guide focuses on PHP technical concerns—we won’t cover organizational change management, stakeholder communication, or unrelated infrastructure upgrades. Those topics, though important, are beyond our scope.

The Challenge: Backward Breaking Changes

Backward compatibility, in this context, refers to code written for an older PHP version running correctly on a newer version. A “breaking change” occurs when PHP’s evolution renders previously valid code invalid — function removals, syntax changes, or altered behavior that triggers errors or unexpected results.

These breaking changes serve legitimate purposes: removing insecure functions, fixing long-standing inconsistencies, and enabling new language features. The each() function provides a clear example. Deprecated in PHP 7.2 and removed in PHP 8.0, its elimination removed a confusing construct that didn’t align with PHP’s iterator patterns — though this left legacy code broken.

Strictly speaking, most breaking changes aren’t arbitrary; they reflect PHP’s maturation. The language maintainers weigh removal costs against long-term coherence. Still, for teams maintaining codebases, the practical impact is the same: what worked yesterday may fail today. Our goal is to manage these breaking changes systematically, transforming them from unpredictable production failures into planned engineering tasks. Success depends on understanding what changed, finding those occurrences in your codebase, and applying fixes methodically.

A Phased Strategy for a Safe Upgrade

Upgrading PHP in production requires careful planning to avoid service disruption. A phased approach — analyzing first, then upgrading in a controlled environment, then deploying — replaces uncertainty with predictability.

These three phases form a systematic process: discover what needs fixing, implement the fixes in isolation, then roll out with confidence. While the details will vary based on your application’s size and complexity, this framework scales from small projects to large enterprise codebases.

Phase 1: Analysis and Preparation

Rushing this step compromises every subsequent phase. Take time to understand the scope of work before writing a single line of code.

  1. Establish a Baseline: Ensure you have a comprehensive test suite—unit tests for individual components, integration tests for interactions, and end-to-end tests for critical user journeys. Your tests are your safety net. If you lack adequate coverage, prioritize building tests for the most critical application paths before proceeding.

  2. Use Static Analysis: Tools like PHPStan and Psalm analyze your code without executing it, detecting compatibility issues with your target PHP version. This provides the fastest high-level overview of the work ahead. PHPStan and Psalm have different philosophies — PHPStan tends to be more strict out of the box, while Psalm offers more fine-grained configuration. Either works; choose based on your team’s familiarity and existing tooling.

One may wonder: why rely on static analysis instead of just running tests? The answer is straightforward: static analysis finds issues that tests don’t exercise—dead code paths, type mismatches in rarely-taken branches, and deprecations that only trigger under specific conditions. It’s a proactive safety net, not a reactive measure.

Let’s walk through setting up PHPStan to check PHP 8.2 compatibility:

Before proceeding, ensure your test suite is committed to version control. Static analysis tools like PHPStan suggest changes, but they don’t modify your code—and you’ll want the ability to diff changes later.

First, install PHPStan as a development dependency: bash $ composer require --dev phpstan/phpstan Note: The exact version installed will depend on your Composer configuration and the latest stable release at the time. The command above installs the current version; if you need a specific version for compatibility reasons, you can specify it with --dev phpstan/phpstan:1.10.0 (for example).

Next, create a configuration file (phpstan.neon) in your project root: neon parameters: level: 0 # Start at level 0 and increase gradually paths: - src The level parameter controls strictness; level 0 is permissive, while level 8 (maximum) applies all available checks. We start low to avoid overwhelming change volume; increasing incrementally yields sustainable progress. Of course, if your project already uses PHPStan, you may have higher levels established—adjust accordingly.

Now run PHPStan to analyze your code: bash $ vendor/bin/phpstan analyse neon parameters: level: 0 # Start at level 0 and increase gradually paths: - src

Now run PHPStan to analyze your code: bash $ vendor/bin/phpstan analyse

PHPStan will report errors like: bash ------ ------------------------------------------------------------------ Line src/Product.php ------ ------------------------------------------------------------------ 28 PHP 8.2 compatibility: Dynamic properties are deprecated ------ ------------------------------------------------------------------

Each error includes file and line number; the specific PHP version deprecation messages help prioritize fixes. As you resolve issues, increase the level value in your configuration — typical progression is level 0 to level 8 (max) over time. The exact error messages you see will vary based on your codebase; the important thing is to address them systematically rather than ignoring lower-level warnings.

Of course, PHPStan isn’t the only option—Psalm is another excellent choice. Both tools integrate with CI/CD pipelines to prevent regressions. The choice between them often comes down to philosophy: PHPStan emphasizes correctness out of the box with stricter defaults, while Psalm provides more granular control and better handles generic types. For most teams, either tool works; if you already use one, stick with it. The key is having static analysis, not which specific tool you choose.

  1. Automate Refactorings with Rector: Rector automatically fixes many breaking changes — syntax upgrades, deprecated function replacements, and version-specific adjustments. While you could make these changes manually, Rector handles them consistently across large codebases.

Install Rector as a development dependency: bash $ composer require --dev rector/rector

Create a rector.php configuration: ```php use Rector\Config\Configuration; use Rector\Set\ValueObject\SetList;

return static function (Configuration $configuration): void {
    $configuration->paths([
        __DIR__.'/src',
        __DIR__.'/tests'
    ]);
    
    // Apply PHP-specific upgrade rules
    $configuration->sets([
        SetList::PHP_80,      // Adjust based on your target version
        SetList::CODE_QUALITY,
    ]);
};
```

Before we proceed, a crucial safety reminder: Rector modifies your code automatically. While it’s designed to make safe, correct transformations, automated refactoring is never completely risk-free. Ensure your project is under version control—preferably with a clean working directory and recent commit—before running Rector. That way, you can review changes and roll back if needed. Strictly speaking, Rector’s transformations are deterministic, but the complexity of real-world codebases means edge cases exist.

Run Rector in dry-run mode first: bash $ vendor/bin/rector process src --dry-run

Review the proposed changes thoroughly. Rector will show a diff of all modifications—examine these carefully, focusing on areas where your codebase has custom logic or unusual patterns. Though Rector is reliable, human review catches edge cases machines miss. When satisfied, apply them: bash $ vendor/bin/rector process src

Rector handles common transformations like:

  • Replacing each() with key()/current() combinations
  • Converting array() syntax to []
  • Upgrading nullable types and union types
  • Hundreds of other version-specific changes

Note that Rector’s sets correspond to specific PHP versions: SetList::PHP_70, SetList::PHP_71, SetList::PHP_72, SetList::PHP_73, SetList::PHP_74, SetList::PHP_80, SetList::PHP_81, and SetList::PHP_82. Select the set matching your target version. Some teams iterate through multiple sets—for example, applying PHP_70 then PHP_71 sequentially—while others use only the final target version set. Both approaches work; the iterative method produces smaller, more reviewable changes, while the target-only method is faster. Choose based on your team’s preference for incremental review versus raw efficiency.

  1. Read the Official Migration Guides: The PHP website provides migration guides for each version. These documents are the authoritative source for every change, deprecation, and new feature. Review the guides for all versions between your current and target version; skipping intermediate releases risks missing cumulative breaking changes.

For example, upgrading from PHP 7.4 directly to PHP 8.2 requires reading the PHP 8.0, 8.1, and 8.2 migration guides. Breaking changes accumulate—code valid in 7.4 may break in 8.2 due to multiple independent changes across versions.

Phase 2: The Upgrade-Test-Fix Cycle

Now we execute the upgrade in a controlled, isolated environment — never directly on production.

  1. Create a Dedicated Environment: Start a new git branch. Use a development server—local installation, Docker container, or staging server—running your target PHP version. Docker is particularly useful here because you can spin up multiple PHP versions side by side. For instance, you might use:

    $ docker run -d -p 8080:80 -v $(pwd):/var/www/html php:8.2-apache

    Verify the PHP version in your container: docker exec <container> php -v.

  2. Update Dependencies: Modify your composer.json to require the new PHP version. Before doing so, though, commit any outstanding changes or create a backup branch—composer update will modify your composer.lock file, and you’ll want to be able to revert if needed.

    {
        "require": {
            "php": "^8.2"
        }
    }

    Then run composer update to refresh your dependencies. Some dependencies may require updates for PHP 8.2 compatibility; Composer will resolve these automatically in most cases. Note that the exact version numbers in your updated composer.lock will vary depending on the latest compatible releases at the time you run this command.

  3. Run Tests and Iterate: Execute your full test suite. It will almost certainly fail—this is expected. Work through the failures systematically:

    • Address static analysis errors from PHPStan first—these often reveal the most pervasive issues.
    • Run tests again, fix new failures, repeat.
    • Use the migration guide as a reference for unexpected behaviors.

One may wonder: what if the test suite itself fails to run under the new PHP version? That’s common—pure syntax errors in tests can prevent test discovery. In such cases, run PHPStan to identify and fix the syntax issues first.

Example: Replacing each()

The each() function, deprecated in PHP 7.2 and removed in PHP 8.0, provides a clear case. Though many developers never encountered each() directly, legacy codebases—particularly those migrated from PHP 5.x—often contain it.

Consider this snippet:

// Before (PHP 7.x)
$users = ['alice', 'bob', 'charlie'];
reset($users);

while (list($key, $value) = each($users)) {
    echo "Key: $key, Value: $value\n";
}

Let’s walk through what happens here and how to migrate safely:

Step 1: Understand the original behavior. The each() function returns the current key-value pair from the internal array pointer and advances the pointer. It returns false when the pointer reaches the end. This loop iterates through all users.

Step 2: Identify the breaking change. PHP 8.0 removed each() entirely—calling it triggers a “Call to undefined function each()” error. The code won’t run at all.

Step 3: Apply the migration. There are two common approaches. The direct replacement uses pointer functions explicitly:

$users = ['alice', 'bob', 'charlie'];
reset($users);

while ($key !== null && $value !== null) {
    $key = key($users);
    $value = current($users);
    echo "Key: $key, Value: $value\n";
    next($users);
}

Alternatively, many teams refactor entirely to foreach, which is both simpler and more idiomatic in modern PHP:

// After (PHP 8.x) - preferred approach
foreach ($users as $key => $value) {
    echo "Key: $key, Value: $value\n";
}

One may wonder: why choose the foreach approach? The answer is twofold. First, foreach expresses intent more clearly—it iterates, nothing more. Second, it avoids the pitfalls of shared internal pointers, which can cause subtle bugs when arrays are passed between functions. Though the pointer-based version is sometimes necessary for very specific edge cases (like modifying the array during iteration with while control), foreach is almost always preferable.

After refactoring, verify your changes by running tests with your target PHP version. If you don’t have tests for this code path, add them now—you’ve just changed behavior, and tests confirm the change matches expectations.

Phase 3: Deployment and Monitoring

Deployment isn’t the end—it’s the final validation stage. Plan it carefully.

One may wonder: after all this testing, why the need for careful deployment? The answer is that real-world environments differ—configuration mismatches, extension differences, resource constraints—in ways that isolated testing may not reveal.

  1. Deploy to Staging: Push your changes to a staging server that mirrors production as closely as possible—same configuration, same data volumes (anonymized if needed). Conduct final manual testing and quality assurance. One may wonder: why not skip staging and deploy directly? The answer is that staging surfaces environment-specific issues that your local environment may not reveal. Though it adds time, the cost of a production failure far outweighs the staging effort.

  2. Deploy to Production Using Rollout Strategies: Deploy during low-traffic periods. Consider canary releases or blue-green deployments to limit blast radius. Have an immediate rollback plan: one must assume any production deployment could introduce unforeseen issues. Keep the previous version ready to redeploy. If you use containers or orchestration tools, leverage their built-in rollback mechanisms—but test the rollback procedure itself beforehand, as failures under pressure are the worst time to discover broken rollback steps.

  3. Monitor Continuously: Monitor error logs, application performance metrics, and user-reported issues for at least 24-48 hours post-deployment. Some edge cases only appear under real-world load patterns. Set up alerts for error rate increases or performance degradations. Pay special attention to deprecation warnings that may have been hidden—PHP’s error reporting settings can mask issues that become visible in production. Though you’ve tested thoroughly, monitoring catches what testing missed.

Conclusion

Upgrading PHP systematically is a manageable engineering task—not a high-risk gamble. By following this phased approach—analysis first, then controlled upgrading, then careful deployment—you maintain backward compatibility while gaining the security and performance benefits of newer PHP versions.

The strategy outlined here—static analysis with PHPStan or Psalm, automated refactoring with Rector, thorough testing, and staged deployment—provides a repeatable process. Of course, no process eliminates all risk; unexpected edge cases will arise. The goal is to reduce that risk to an acceptable level, not to pretend it can be eliminated entirely.

Of course, this guide focuses on PHP-specific concerns—we haven’t covered deployment infrastructure, CI/CD pipeline setup, or organizational change management. Those topics, though relevant, are beyond our scope. Still, the principles apply broadly: understand what changes, automate what you can, test comprehensively, and proceed incrementally. Organizations that institutionalize this approach find that subsequent upgrades become easier—the tooling investment pays compound interest.

Each successful upgrade builds confidence in your codebase’s resilience and your team’s upgrade process. That confidence is perhaps the most valuable outcome: it transforms an intimidating event into a routine maintenance task.

Sponsored by Durable Programming

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

Hire Durable Programming