PHP Migration Across Versions — From PHP 5 to PHP 8.5 Without Breaking Production

Build a working playbook for moving a real codebase across PHP versions: what tends to get removed, deprecated, and behaviorally changed at each major transition from PHP 5 through 8.5; how extension and dependency compatibility factor in; a concrete test strategy for before, during, and after a migration; using Rector to automate the mechanical parts; and staged/incremental migration with a real rollback plan.

PHP's major-version transitions have not been uniformly risky. PHP 5 to 7 was a huge internal rewrite with real behavioral changes but a comparatively narrow set of removed userland features; PHP 7.4 to 8.0 broke more application-level assumptions than any transition since; PHP 8.1 through 8.5 have each been comparatively gentle, mostly additive releases with a handful of deprecations rather than sweeping removals. Treating every major-version bump as equally risky wastes effort; treating them as uniformly safe is how a 'quick PHP upgrade' turns into a multi-week incident. This lesson lays out what actually changes at each step along the path, and the concrete process -- testing, automated rewriting with Rector, staged rollout, rollback planning -- that turns 'upgrade PHP' from a gamble into a routine, low-drama engineering task.

PHP 5 to PHP 7: A Rewrite Under the Hood, Real Behavior Changes on Top

PHP 7's engine (internally called phpng) rewrote the core zval and memory-management internals for roughly double the performance and about half the memory usage of PHP 5.6 on typical workloads -- a huge win, but one that came with real userland-visible behavior changes rather than being purely an internal detail. The exception hierarchy changed fundamentally: fatal errors that PHP 5 could not catch at all (calling a method on a non-object, for instance) became catchable Error exceptions in PHP 7, under a new Throwable interface that both Error and the pre-existing Exception implement -- meaning a bare catch (Exception $e) written for PHP 5 silently stops catching a whole category of failures it used to (accidentally) let crash the script outright, which is now instead an uncaught Error.

PHP 7 also removed the old mysql_* extension entirely (already deprecated since 5.5, in favor of mysqli/PDO), removed ASP-style <% %> tags, and changed foreach's handling of the internal array pointer and by-reference iteration in ways that could subtly change output for code relying on the old pointer behavior. Because so much PHP 5-era code (especially anything still calling mysql_* functions directly) required real rewriting rather than just a recompile-and-go, PHP 5-to-7 migrations were often the single largest-effort transition a long-lived codebase would ever undertake -- which is precisely why so many production codebases skipped 5.x's later releases and jumped straight to 7 once ready, rather than migrating incrementally within the 5.x line.

• phpng engine rewrite -- roughly 2x throughput, ~half the memory, of PHP 5.6. • New Throwable/Error hierarchy -- previously-fatal errors became catchable, but a bare catch(Exception) from PHP 5 no longer catches them. • mysql_* extension removed entirely -- any remaining direct callers require a real rewrite to mysqli/PDO. • Often the single largest-effort PHP migration a codebase undergoes, due to the sheer accumulated age of PHP 5-era code.

PHP 7.4 as an LTS Anchor, and the Sharp Turn at PHP 8.0

PHP 7.4 -- with typed properties, arrow functions, and preload -- became, in practice, the long-lived resting point for a huge share of the PHP ecosystem before committing to PHP 8, both because it was a mature, well-supported final 7.x release and because PHP 8.0 introduced more backward-incompatible changes than any release since 7.0. String-to-number comparison rules changed (0 == 'foo' evaluates to false in PHP 8, where it was true in PHP 7 -- a change that silently flips the result of comparisons all over legacy code that relied on the old 'loose' behavior), and the concatenation operator . now binds LOWER precedence than +/-, meaning an expression like 'Total: ' . $a + $b parses differently than it did before.

PHP 8.0 removed PHP 4-style constructors (a method with the same name as its class, rather than __construct), removed the curly-brace string-offset syntax ($str{0}, already deprecated), removed several long-deprecated function aliases, and changed the @ error-suppression operator so it no longer silences fatal errors. It also converted several long-standing 'resource' types to proper objects across bundled extensions -- most notably curl, where a curl_init() call now returns a CurlHandle object instead of a resource -- which silently breaks any code doing is_resource($ch) or a gettype()-based switch expecting the old resource type string.

• 7.4 -- widely used as a stable staging point before committing to the 8.x line. • 8.0 -- string-to-number comparisons changed; . now binds lower precedence than +/-. • 8.0 -- PHP 4-style constructors and curly-brace string offsets removed entirely. • 8.0 -- curl (and other) resources became objects (CurlHandle); is_resource()/gettype()-based checks on them silently break.

PHP 8.1 Through 8.4: Enums, Readonly, DNF Types, and Property Hooks

PHP 8.1 added enums, readonly properties, fibers, and the never return type, alongside a meaningful deprecation: passing null to an internal function parameter typed without an explicit ? prefix is deprecated (a common source of new deprecation-notice noise on an 8.0-to-8.1 jump, since it affects countless calls into built-in functions across a typical codebase, not just application code). PHP 8.2 added readonly CLASSES (not just properties), disjunctive normal form (DNF) types, and deprecated implicit dynamic properties on ordinary classes -- code that assigns to an undeclared property on a normal class ($obj->newProp = 'value'; where newProp was never declared) now emits a deprecation notice unless the class opts back in with #[AllowDynamicProperties], which is a genuinely common migration item for older codebases that relied on that implicit behavior, intentionally or not.

PHP 8.3 added typed class constants and the #[Override] attribute (catching a method meant to override a parent's that no longer actually matches its signature); PHP 8.4 added property hooks and asymmetric visibility (public private(set)), rewrote the DOM extension for HTML5 compliance, and finally turned the PHP 8.1 implicit-nullable-parameter deprecation into a harder-enforced deprecation ahead of an eventual removal. None of 8.1 through 8.4 removed as much userland-visible behavior as 8.0 did -- they are, in aggregate, a much gentler slope than the 7.4-to-8.0 jump, which matters directly when estimating migration effort.

• 8.1 -- enums, readonly properties, fibers; deprecates implicit-nullable params on internal functions. • 8.2 -- readonly classes, DNF types; deprecates dynamic properties on ordinary classes without #[AllowDynamicProperties]. • 8.3 -- typed class constants, #[Override]. • 8.4 -- property hooks, asymmetric visibility, HTML5-compliant DOM. • 8.1-8.4 collectively remove far less than 8.0 did -- a genuinely gentler migration slope than the 7.4-to-8.0 jump.

Extension and Dependency Compatibility Across a Migration

Every PHP release can break ABI (application binary interface) compatibility with compiled extensions -- the Zend Engine's internal API can and does change between minor versions, not just majors, which means a compiled extension (xdebug, redis, imagick, swoole) built against PHP 8.1 is not guaranteed to load, let alone work correctly, under PHP 8.3 without being recompiled against the new version specifically. Before committing to a target PHP version, check every compiled extension the application actually depends on for an explicit statement of support for that version -- extensions that wrap actively-changing native libraries (image processing, high-performance networking) are frequently the slowest to catch up to a new PHP release, and can become the actual blocker on an otherwise-ready migration.

On the userland side, Composer dependencies commonly pin a "php" constraint in their own composer.json ("php": "^7.4", for instance) that will simply refuse to install at all under a newer PHP major version until the package itself is updated -- composer why-not php 8.2 surfaces exactly which installed packages are blocking a target version and why, which is the first practical step in scoping a migration's true size before touching any application code.

• Compiled extensions (xdebug, redis, imagick, swoole, ...) generally need recompiling/reinstalling for each target PHP version -- verify explicit support before committing to a target. • composer why-not php <target-version> surfaces exactly which installed packages block the jump and why. • Extensions wrapping actively-changing native libraries tend to lag furthest behind new PHP releases -- check these first.

Test Strategy Before, During, and After a Migration

BEFORE touching the PHP version: make sure the automated test suite actually covers the application's critical paths (a migration is the worst possible moment to discover a huge untested surface area), and run static analysis (PHPStan or Psalm, ideally at a high strictness level) against the CURRENT PHP version to surface type-related issues the old runtime tolerated but a stricter new one might not. DURING the transition: run the full test suite under BOTH the current and target PHP versions in a CI matrix build simultaneously for a sustained window, and turn on E_DEPRECATED reporting (logged, not failing the build yet) specifically to inventory every deprecation the codebase actually triggers under the target version before flipping any environment over to it -- this converts 'surprise incident after the upgrade' into 'known, prioritized backlog before it.'

AFTER cutting over: watch error tracking closely, specifically for TypeErrors and deprecation notices coming from REAL production traffic patterns that the test suite's coverage didn't happen to exercise -- production traffic reliably finds edge cases automated tests miss, especially around type coercion changes. Keep the CI matrix running against the new minimum version going forward rather than deleting it immediately, since it's cheap insurance against a regression being introduced that only breaks the (now standard) newer version.

• Before: verify meaningful test coverage on critical paths; run static analysis at high strictness against the CURRENT version first. • During: run the full suite under BOTH versions in a CI matrix; log (don't fail on) E_DEPRECATED under the target version to inventory the real blast radius. • After: watch error tracking specifically for TypeErrors/deprecations from real traffic the tests didn't cover; keep the matrix build running afterward.

Automating the Grunt Work with Rector

Rector is a static PHP-to-PHP rewriting tool driven by declarative rule sets -- Rector\Php80\Set\Php80SetList::PHP_80, for instance -- that mechanically applies syntax-level upgrades across an entire codebase far faster and more consistently than manual find-and-replace: converting eligible constructors to use constructor property promotion, rewriting suitable switch statements to match expressions, adding parameter/return types Rector can infer with confidence, and hundreds of other targeted, version-specific rewrites. Because Rector operates on an AST (abstract syntax tree) rather than blind text substitution, its rewrites are syntactically correct by construction -- but they are not a substitute for actually understanding what changed; Rector rewrites SYNTAX, it does not verify that the resulting code is behaviorally correct.

In practice, Rector runs on its own dedicated branch, its full diff gets reviewed like any other large, mechanical change (it CAN produce large and initially noisy-looking diffs across a big codebase), and it's paired directly with the test-strategy section above -- run the test suite before and after Rector's rewrite, on the SAME PHP version, to confirm the mechanical transformation didn't change behavior, before even considering flipping the runtime version itself.

• Rector -- AST-based, declarative rule sets (e.g. PHP_80, PHP_81 level sets) that mechanically rewrite code for a target PHP version. • Rewrites syntax correctly by construction, but does not verify runtime behavior -- pair every run with the existing test suite. • Run Rector on its own branch; review its diff like any other large change before merging. • Rector also has downgrade rule sets for the reverse direction, useful for libraries that must support older PHP while being developed on a newer one.

Staged/Incremental Migration and Rollback Planning

A 'big bang' cutover -- flipping 100% of production traffic to the new PHP version at once -- concentrates all of a migration's risk into a single moment. Staged approaches spread that risk out: running the target PHP version behind a canary (a small percentage of traffic, or a single node in a larger fleet) with the SAME monitoring and alerting coverage as the rest of the fleet, for a real soak period, before widening the rollout; or running both versions side by side behind a load-balancer split that can be dialed up or down without a full redeploy; or, in a modular codebase, migrating module by module where the architecture genuinely allows it.

Rollback planning is not optional insurance to skip when a migration 'feels safe' -- it's the plan for the scenario where the staged rollout above actually catches a real problem. Keep the PREVIOUS PHP-version deployment artifact (container image, build) genuinely deployable, not deleted, for at least one full release cycle after the migration completes. Pay special attention to any DATABASE migrations applied during the same window: a schema change that only the NEW code understands (a new non-nullable column the old code never populates, say) blocks a clean rollback of the application code alone -- schema changes during a version migration should themselves be backward compatible with the version being rolled back FROM. Finally, rehearse the rollback procedure itself before it's ever needed under real incident pressure, not for the first time during one.

• Canary rollout -- new PHP version on a small traffic percentage or one node, with full monitoring parity, before wider rollout. • Keep the previous version's deploy artifact genuinely deployable for at least one full release cycle after cutover. • Database schema changes made during the migration window must remain backward compatible with the version being rolled back FROM. • Rehearse the rollback procedure before an incident forces you to improvise it.

Code Examples

UP_TO_PHP_82 bundles every version-specific rule set from PHP 8.0 through 8.2 into one run, so the codebase is mechanically brought up to modern syntax in one pass rather than one PHP-version-at-a-time. Excluding a legacy directory via skip() is a common, deliberate choice when part of a codebase is slated for removal rather than modernization -- Rector should not spend effort (or risk introducing subtle bugs) rewriting code that's going away.

$rectorConfig->sets([LevelSetList::UP_TO_PHP_82, SetList::CODE_QUALITY]);

ConfigBefore's loop can assign to any property name found in $values, including ones the class never declared -- exactly the pattern PHP 8.2 deprecates. ConfigAfter fixes it two ways at once: declaring every expected property up front (so assignment to a KNOWN property is never 'dynamic'), and guarding the loop with property_exists() so an unexpected key is silently ignored rather than creating a new dynamic property. ExternalPayload shows the escape hatch for the rare case where dynamic properties are genuinely the intended design, not an oversight.

if (property_exists($this, $key)) { $this->$key = $value; } Common mistake: Blanket-adding #[AllowDynamicProperties] to every class that triggers this deprecation during a migration, instead of taking the opportunity to actually declare the properties -- it silences the deprecation notice but keeps the same lack of structure that caused it, for classes where that structure would genuinely help.

This handler only intercepts E_DEPRECATED/E_USER_DEPRECATED, logging each one with its exact file and line instead of letting it silently vanish or clutter normal application output -- returning true tells PHP the deprecation was handled so it doesn't ALSO print through the default handler, while returning false for every other error level defers entirely to PHP's normal behavior. Running this specifically in the target-version CI job and a canary environment turns 'unknown deprecation debt' into a concrete, file-and-line-numbered backlog before the wider rollout.

if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {

<?php
declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ]);

    // Applies every rule up through the PHP 8.2 level set (8.0, 8.1, and 8.2
    // rules together) -- run this on its own branch, review the diff, then
    // run the existing test suite BEFORE changing the runtime PHP version.
    $rectorConfig->sets([
        LevelSetList::UP_TO_PHP_82,
        SetList::CODE_QUALITY,
    ]);

    $rectorConfig->skip([
        __DIR__ . '/src/Legacy', // intentionally excluded from automated rewrites
    ]);
};
<?php
declare(strict_types=1);

// BEFORE (PHP < 8.2): relies on implicitly creating an undeclared property.
// Under PHP 8.2+ this triggers: "Deprecated: Creation of dynamic property
// Config::$cacheTtl is deprecated"
final class ConfigBefore
{
    public function __construct(array $values)
    {
        foreach ($values as $key => $value) {
            $this->$key = $value; // may create properties never declared below
        }
    }
}

// AFTER: declare every expected property explicitly, so nothing is "dynamic".
final class ConfigAfter
{
    public string $environment = 'production';
    public int $cacheTtl = 3600;
    public bool $debug = false;

    public function __construct(array $values)
    {
        foreach ($values as $key => $value) {
            if (property_exists($this, $key)) {
                $this->$key = $value;
            }
        }
    }
}

// If a class genuinely needs arbitrary dynamic properties (e.g. modeling a
// loosely-structured external payload), opt back in explicitly instead of
// silently relying on pre-8.2 default behavior:
#[AllowDynamicProperties]
final class ExternalPayload
{
}
<?php
declare(strict_types=1);

// Registered early in bootstrap, only in the CI matrix's "target version"
// job and in a canary environment -- collects deprecation notices from
// real code paths without breaking the request.
set_error_handler(
    function (int $errno, string $errstr, string $errfile, int $errline): bool {
        if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {
            error_log(sprintf(
                '[php-migration] %s in %s:%d',
                $errstr,
                $errfile,
                $errline
            ));

            return true; // handled: don't also run PHP's default handler
        }

        return false; // let PHP's default handler manage everything else
    },
    E_DEPRECATED | E_USER_DEPRECATED
);

The PHP version migration path, PHP 5 through PHP 8.5

Migration risk is not evenly distributed along this path: 5-to-7 is a large rewrite effort mostly from PHP 5's own age, 7.4-to-8.0 is the sharpest single backward-incompatible jump in the whole line, and every step from 8.1 onward has been comparatively additive -- which should directly inform how much test/staging effort each individual hop actually needs.

PHP 5  --->  PHP 7  --->  PHP 7.4  --->  PHP 8.0  --->  PHP 8.1  --->  PHP 8.2  --->  PHP 8.3  --->  PHP 8.4  --->  PHP 8.5
  |            |             |              |              |              |              |              |              |
  |            |             |              |              |              |              |              |              |
mysql_*     phpng engine   typed         Throwable/     enums,        readonly       typed class   property hooks,  pipe |>,
extension   rewrite,       properties,   Error split,   readonly      classes,       constants,    asymmetric        clone-with,
still in    huge perf/     arrow fns,    string<->num   properties,   DNF types,     #[Override]   visibility,       URI ext,
wide use    mem win        preload       compare rules  fibers,       dynamic props                HTML5 DOM         #[\NoDiscard]
                                         changed,        never type    deprecated
                                         . vs + prec.
                                         flipped,
                                         resources ->
                                         objects (curl)

  BIGGEST REWRITE EFFORT  
…

Important Rules

Never skip straight from PHP 5 to PHP 8+ in one step -- migrate sequentially so each transition's specific BC breaks can be isolated and tested.

Check composer why-not php <target> and every compiled extension's stated version support BEFORE estimating a migration's effort.

Run the full test suite under both current and target versions in CI during the transition window, not just after cutover.

Use Rector to automate mechanical syntax upgrades, but always re-run the test suite afterward -- Rector verifies syntax, not behavior.

Stage the rollout (canary, then wider) rather than a single big-bang cutover for any non-trivial production application.

Keep the previous version's deploy artifact deployable, and keep migration-window schema changes backward compatible, until the rollback window has passed.

Why This Matters in Production

The 7.4-to-8.0 transition produced more real production incidents industry-wide than any other recent PHP migration precisely because teams underestimated it relative to the smaller, additive-feeling 8.1-through-8.4 jumps that came after -- the string comparison and curl-resource-to-object changes in particular caught codebases that had extensive but not fully comprehensive test coverage. Teams that treat every major-version bump with the SAME fixed process (dependency/extension audit, Rector pass, dual-version CI matrix, staged rollout with a real rollback plan) rather than judging each one's risk by feel are the ones who consistently turn PHP upgrades into routine, low-drama maintenance instead of dreaded, deferred-for-years projects.

composer.json: "php": ">=7.4" // then deploying straight to PHP 8.0 with no testing under 8.0 first

Run the full test suite under a PHP 8.0 CI job for a sustained window (with E_DEPRECATED logged) BEFORE changing any production environment's PHP version, then widen "php" in composer.json only once that's clean.

A composer.json constraint that technically ALLOWS a newer PHP version says nothing about whether the code has actually been verified to behave correctly under it -- PHP 8.0 in particular introduced enough behavioral changes (string-to-number comparisons, resource-to-object conversions) that 'it installs' and 'it works correctly' are very different claims.

[multiple-choice] Which PHP transition on the migration path introduced the sharpest set of backward-incompatible changes? A. PHP 8.1 to 8.2 B. PHP 7.4 to 8.0 C. PHP 8.3 to 8.4 D. PHP 8.4 to 8.5

B. PHP 7.4 to 8.0 — PHP 8.0 changed string-to-number comparison rules, flipped concatenation-vs-arithmetic operator precedence, removed PHP 4-style constructors and curly-brace string offsets, and converted several resource types to objects -- more backward-incompatible surface area than any other transition on the path.

[multiple-choice] What does Rector verify when it rewrites code for a target PHP version? A. Both syntax correctness and runtime behavioral correctness B. Only that the rewritten code is syntactically correct for the target version -- not that its runtime behavior is unchanged C. Only runtime performance D. Database schema compatibility

B. Only that the rewritten code is syntactically correct for the target version -- not that its runtime behavior is unchanged — Rector operates on the AST and produces syntactically valid, version-appropriate code, but it does not verify runtime behavior -- that's exactly why every Rector run should be paired with the existing test suite before merging.

[multiple-choice] During a staged migration, why must database schema changes made in that window remain backward compatible with the version being rolled back FROM? A. Schema changes never affect rollback plans B. A schema change only the new code understands (e.g. a new non-nullable column old code never populates) blocks a clean rollback of the application code alone C. PHP automatically migrates schemas on rollback D. It's only a concern for SQLite databases

B. A schema change only the new code understands (e.g. a new non-nullable column old code never populates) blocks a clean rollback of the application code alone — If the schema changes in a way only the NEW application code understands, rolling the application code back to the previous version leaves it unable to correctly read/write the now-changed schema -- so migration-window schema changes need to stay compatible with both versions until the rollback window has passed.

A team is still on PHP 7.4 in 2026 and wants to reach PHP 8.3. Using this lesson's material, outline the migration plan: which intermediate version(s) to stop at, what to check before starting, and how to structure the rollout.

Given 7.4-to-8.0 is the sharpest backward-incompatible jump on the whole path, the plan should isolate it: first audit composer why-not php 8.0 and every compiled extension's 8.0 support, run Rector's PHP_80 set on a branch and re-test under 7.4, then run the full suite under an 8.0 CI job with E_DEPRECATED logged for a sustained window, and do a canary rollout to PHP 8.0 alone before widening. Only once 8.0 is stable in production should the team repeat a (much lighter, since 8.1-8.3 are comparatively gentle) version of the same process to reach 8.1, then 8.2, then 8.3 -- either as three quick, low-risk sequential hops or, if the deprecation audits for 8.1-8.3 come back clean, potentially combined into fewer canary stages, but always verified with the dual-version CI matrix at each stop rather than skipped on assumption.

Think about where the sharpest BC break sits on the path, and why stopping there first (rather than jumping straight to 8.3) reduces risk.