A Practical Strategy for Migrating a Legacy PHP Application

Plan and execute a real upgrade of a legacy PHP application: stepping PHP versions incrementally, using compatibility analysis and static analysis tools, building a safety net of tests before changing code, managing Composer dependency upgrades, and choosing an incremental modernization strategy over a risky rewrite.

Once you can recognize legacy PHP patterns, the next skill is knowing what to actually do about them without breaking a production application that real users depend on. Migrating an old PHP codebase forward is rarely as simple as changing a version number and fixing whatever errors show up — done carelessly, it's one of the easiest ways to take down a business-critical system for days. This lesson walks through the practical, battle-tested process professional teams use: upgrading in small steps, analyzing compatibility before touching code, leaning on tests and static analysis as a safety net, handling dependency upgrades, using automated refactoring tools, and choosing an incremental strategy over a big-bang rewrite.

Upgrade the Runtime Incrementally, Never in One Giant Leap

The single most important rule of PHP version migration is to never jump straight from an old major version to the newest one — going from PHP 5.6 directly to PHP 8.3, for example, compounds every breaking change from PHP 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 8.1, 8.2, and 8.3 into one undifferentiated pile of failures, with no way to tell which change caused which new error, and no safe rollback point in between. Each individual PHP minor and major release publishes its own list of backward-incompatible changes and deprecations (in the official migration guide for that version, e.g. 'Migrating from PHP 8.1.x to PHP 8.2.x'), and those lists are written and tested assuming you're coming from the immediately preceding version, not from three major versions back.

The professional approach is to upgrade one version at a time — sometimes two minor versions if the gap between them is small and well-documented — running the full application (and its test suite, covered below) after each single-version bump, fixing whatever that specific version's migration guide flags, and only then moving to the next one. A migration from PHP 5.6 to PHP 8.3 in practice looks like: 5.6 → 7.0 → 7.1 → 7.2 → 7.3 → 7.4 → 8.0 → 8.1 → 8.2 → 8.3, with a working, deployed (or at least fully tested in staging) application at every intermediate stop. This is slower than a single jump, but it turns one overwhelming, undebuggable migration into nine small, comprehensible, individually reversible ones.

It's worth budgeting real calendar time for this: a large legacy application with genuine technical debt can take weeks or months to move through several major PHP versions safely, and trying to compress that timeline by skipping versions is the most common reason legacy migrations fail, get abandoned halfway, or ship serious regressions to production.

• Never jump multiple major PHP versions at once — go one version (occasionally two, if well-documented) at a time. • Each version's official migration guide lists breaking changes and deprecations relative to the immediately prior version only. • A 5.6 → 8.3 migration is really nine small migrations, not one: 5.6→7.0→7.1→7.2→7.3→7.4→8.0→8.1→8.2→8.3. • Run the full application and test suite after every single-version bump before proceeding to the next. • Budget realistic calendar time — compressing the timeline by skipping versions is the top cause of failed migrations.

Compatibility Analysis Before You Touch a Line of Code

Before upgrading anything, the first useful activity is finding out how much work the upgrade will actually be — scanning the existing codebase for usage of functions, syntax, and behaviors that the target PHP version deprecates or removes, without changing any code yet. This turns an unknown, anxiety-inducing 'how bad is this going to be' into a concrete, prioritized list: which files use mysql_* functions, which classes have old-style constructors, which code calls functions removed in the target version, which code relies on behavior that quietly changed (like PHP 8's stricter string-to-number comparison rules).

Tools purpose-built for this exist and should be run before any manual code changes. PHPCompatibility, a ruleset for PHP_CodeSniffer, scans a codebase against a specified target PHP version and reports every incompatibility it can detect statically — deprecated function calls, removed extensions, changed function signatures — without executing any of the code. Running it against your current codebase, targeting the *next* version you plan to upgrade to (not the final destination), produces a concrete checklist for that one step of the incremental migration described above.

This upfront analysis step matters because it converts migration work from reactive firefighting (upgrade, see what breaks in production, panic-fix it) into proactive, plannable work (know what will break, fix it calmly on the current, still-working PHP version, then upgrade with much higher confidence). Fixing compatibility issues while still running the *old* PHP version is almost always safer and easier to test than fixing them after the upgrade has already happened and the application is partially broken.

• Compatibility analysis scans code for the target PHP version's incompatibilities, without changing or running anything yet. • PHPCompatibility (a PHP_CodeSniffer ruleset) checks a codebase against a specified target PHP version automatically. • Always target the *next* version in your incremental plan, not the final destination, for each analysis pass. • Fix flagged issues on the current, still-working PHP version before upgrading, rather than upgrading first and firefighting after.

Tests as a Safety Net — and What to Do Without One

A migration's biggest risk isn't the PHP version change itself; it's not knowing whether the application still behaves correctly afterward. A comprehensive automated test suite (unit tests around business logic, integration tests around database and external-service interactions, and ideally some end-to-end tests around critical user flows) is what lets a team upgrade with confidence: run the suite before the upgrade to establish a baseline, run it again after each incremental version bump, and treat any new failure as a real regression to investigate immediately rather than a mystery to debug in production weeks later.

The uncomfortable reality is that most legacy PHP codebases have little or no automated test coverage — that's often *part of* why they became legacy in the first place; nobody felt safe changing them, so nobody added tests, so nobody felt safe changing them, a self-reinforcing cycle. Migrating a genuinely untested application is not impossible, but it does mean the test suite has to be built as a prerequisite to the migration, not skipped.

The standard technique here is writing 'characterization tests' (sometimes called 'golden master' tests): rather than trying to figure out what the code is *supposed* to do from first principles — often impossible with no documentation and the original author long gone — you write tests that simply capture what the code *currently* does for a range of realistic inputs, treating the existing behavior as the specification, bugs and all. A characterization test for a legacy pricing function might feed it a dozen representative product/discount/tax combinations, record whatever output it currently produces for each, and assert that output as the expected result. These tests don't claim the current behavior is *correct* — only that it's *known* — which is exactly the guarantee you need before changing anything: if a characterization test starts failing after a PHP version bump, you know something about behavior changed, and you can then decide deliberately whether that's an acceptable, intentional change or a regression to fix.

• A test suite lets you distinguish 'the upgrade broke something' from 'everything still works' quickly and confidently. • Untested legacy code is common and is often the reason nobody has dared touch it in years. • Characterization ('golden master') tests capture current behavior as the baseline, without claiming that behavior is correct. • Write characterization tests for critical, hard-to-reason-about code paths before migrating them, if no tests exist yet. • A newly failing characterization test after an upgrade is a signal to investigate, not automatically a bug to revert.

Static Analysis as a Whole-Codebase Migration Aid

Static analysis tools examine source code without running it, reasoning about types and control flow to catch a much wider class of problems than tests alone typically cover — and they're exceptionally well suited to migration work because they can scan an entire codebase in seconds and surface issues a human reviewer would take days to find by reading files one at a time. PHPStan and Psalm are the two dominant PHP static analysis tools; both infer and check types across function boundaries, flag calls to deprecated or removed functions, catch type errors that would otherwise only surface as runtime warnings or fatal errors, and can be configured with a 'level' or strictness setting that a team ratchets up gradually as a codebase improves.

For migration specifically, both tools understand PHP version differences: pointed at a codebase with a target PHP version configured, they'll flag usage of functions and features that don't exist yet, or no longer exist, in that version — catching many of the same issues PHPCompatibility catches, but combined with genuine type-checking that also surfaces bugs unrelated to version compatibility (a function called with the wrong number of arguments, a possibly-null value passed where a non-null type is required, a method that doesn't exist on a given class). Running one of these tools regularly during a migration — ideally wired into CI so every change is checked automatically — turns 'did I miss a spot' from a nagging worry into a solved problem the tool answers on every commit.

It's worth being realistic about a first run: pointing PHPStan or Psalm at a legacy codebase that has never used either will usually produce an overwhelming number of findings, many of them in code nobody currently plans to touch. The practical approach is to start at a low strictness level, use a 'baseline' feature both tools provide (which records all *current* findings and suppresses them, so only *new* issues in *changed* code are reported going forward), and ratchet the strictness level up over time as the codebase genuinely improves — rather than trying to fix every historical finding before the migration can even begin.

• PHPStan and Psalm perform whole-codebase type checking and can flag deprecated/removed function usage for a target PHP version. • Static analysis catches many issues (wrong argument counts, possible nulls, undefined methods) beyond pure version compatibility. • Wire static analysis into CI during a migration so every subsequent change is checked automatically, not just the migration commit. • Use a 'baseline' file to suppress pre-existing findings on first run, so only new issues in new/changed code block progress. • Ratchet strictness levels up gradually rather than demanding a fully clean report before starting the migration.

Dependency Upgrades: Composer and the Platform Constraint

A PHP application's own code is rarely the only thing that needs to support the new PHP version — every third-party package pulled in via Composer needs to support it too, and this is frequently the harder half of a migration, because you don't control those packages' release schedules. Before upgrading PHP itself, it's worth running composer outdated to see which installed packages have newer versions available, and checking each dependency's own changelog or composer.json for its declared PHP version support (its own require.php platform constraint) against the version you're targeting.

In practice, upgrading PHP on a real application usually means upgrading a batch of Composer dependencies in the same effort, sometimes across major versions of those packages too — a framework like Laravel or Symfony (covered in more depth in the next lesson) ties its own major versions to specific PHP version ranges, so upgrading PHP far enough eventually forces a framework major-version upgrade as well, which brings its own migration guide and breaking changes to work through. Older, abandoned packages with no maintainer response are the hardest case: if a required dependency hasn't been updated in years and doesn't support the target PHP version, the options are forking and patching it yourself, finding an actively maintained replacement package, or — if the functionality is small — vendoring a minimal reimplementation directly into the application to drop the dependency entirely.

A useful safety habit during this phase is running composer update with --dry-run first to preview what would change without actually installing anything, and committing the resulting composer.lock file changes as their own reviewable step, separate from the PHP-version-compatibility code changes — that way, if something breaks, it's easy to tell whether a dependency upgrade or a code change caused it, rather than debugging both changes tangled together in one commit.

• Every Composer dependency must independently support the target PHP version — this is often the harder half of a migration. • composer outdated and each package's own composer.json php constraint show what needs upgrading before you touch PHP itself. • Upgrading PHP far enough often forces a framework major-version upgrade too, since frameworks pin PHP version ranges per release. • Abandoned dependencies with no PHP-8-compatible release require forking, replacing, or vendoring a minimal reimplementation. • Keep dependency-upgrade commits separate from code-compatibility-fix commits so a regression's cause is easy to isolate.

Automated Refactoring with Rector

Rector is a tool built specifically to mechanically rewrite PHP source code according to a defined set of rules — including rule sets targeting compatibility with a specific PHP version. Rather than a human manually finding and fixing every old-style constructor, every each() loop, or every removed function call across hundreds of files, Rector can apply the equivalent transformation automatically and consistently across an entire codebase in one pass, using its knowledge of PHP's abstract syntax tree rather than fragile text-based find-and-replace.

For a PHP version migration specifically, Rector ships pre-built rule sets like LevelSetList::UP_TO_PHP_82, which bundles together every mechanical rewrite needed to bring code up to PHP 8.2 syntax and idioms — converting old array function calls to their modern equivalents, adding constructor property promotion where applicable, and more. Running Rector is not a substitute for the compatibility analysis, testing, and static analysis steps described above — it's a complement: Rector handles the repetitive, mechanical rewrites so a human's limited attention can focus on the genuinely tricky judgment calls (like deciding how to replace an abandoned dependency, or what the 'correct' behavior of an untested legacy function actually should be).

The safe way to use Rector in a real migration is to run it against a version-controlled codebase, inspect the diff it produces carefully (Rector is powerful and occasionally makes a transformation that's syntactically valid but subtly changes behavior), run the full test suite against the result, and only then commit — treating Rector's output the way you'd treat a very fast, very literal junior developer's pull request: valuable, but reviewed before merging, never blindly trusted.

• Rector mechanically rewrites PHP code using AST-based transformations, not fragile text find-and-replace. • Pre-built rule sets (e.g. targeting a specific PHP version) bundle many compatibility rewrites into one automated pass. • Rector complements manual work — it handles repetitive rewrites so humans can focus on genuine judgment calls. • Always review Rector's diff and run the full test suite before committing its changes, the same as any automated code change.

Incremental Modernization: The Strangler Fig Pattern

Beyond just upgrading the PHP version, teams often want to modernize a legacy application's actual architecture — replacing an old, tangled procedural codebase with a cleaner, better-structured one, adopting a framework where there was none, or introducing proper separation of concerns. The instinctive approach, a 'big-bang rewrite' (freeze the old system, build a brand-new one from scratch, cut over all at once), has a long, well-documented history of failure in software engineering: rewrites routinely take far longer than estimated, the old system still needs bug fixes and new features while the rewrite is underway (creating a moving target the rewrite team can never quite catch up to), and the eventual all-at-once cutover is a single high-stakes event where any missed edge case becomes a production incident affecting the entire application at once.

The strangler fig pattern, named after a plant that grows around a host tree and gradually replaces it, offers a much lower-risk alternative: instead of replacing the whole system at once, you place a stable interface (often literally a routing layer, or an API gateway, or simply careful use of the existing router) in front of the legacy application, and then replace its pieces one at a time behind that interface — a single feature, a single module, a single set of endpoints — redirecting traffic for just that piece to the new implementation once it's built and verified, while everything else continues to flow to the untouched legacy code. Over time, more and more of the old system is 'strangled' by new implementations until eventually nothing of the original remains, but at no single point was the entire application at risk simultaneously.

Applied to PHP specifically, a strangler fig migration might look like: keep the legacy monolith running exactly as-is; build a new, modern PHP application (or a modern module within the same codebase) implementing just one bounded piece of functionality — say, the checkout flow; route only checkout traffic to the new implementation once it's tested and confirmed correct, leaving product browsing, account management, and everything else untouched on the legacy code; then repeat for the next piece. This lets a team ship real, verified improvements continuously, keeps the 'blast radius' of any single mistake small and contained, and — crucially for morale and stakeholder confidence — produces visible progress throughout the process instead of a long, invisible rewrite with nothing to show until a risky final cutover.

• Big-bang rewrites have a well-documented history of running over time/budget while the old system remains a moving target. • The strangler fig pattern replaces a legacy system incrementally, piece by piece, behind a stable interface. • Traffic for each migrated piece is redirected to the new implementation once it's built and verified; everything else stays on the legacy system. • Risk stays contained to whatever single piece is being migrated at any given moment, rather than the whole application at once. • The pattern produces continuous, visible progress instead of a long invisible effort ending in one risky cutover.

Code Examples

Running the scan against 7.4 (the immediate next hop) rather than 8.3 (the eventual goal) keeps the resulting checklist focused and manageable -- fixing everything PHPCompatibility flags here is exactly the work needed to safely take this one step of the incremental upgrade.

If any of these assertions start failing after a PHP version bump or a Rector-driven rewrite, that's a concrete, specific signal that calculate_total()'s behavior changed -- prompting a deliberate decision about whether the new behavior is an acceptable fix or an unintended regression, rather than an unnoticed silent change.

Keeping the dependency-lock-file change and the application-code change in separate commits means that if the application breaks after this stage of the migration, git history immediately shows whether the break came from a third-party package upgrade or from the team's own code changes -- a distinction that's very hard to reconstruct after the fact from a single combined commit.

# Install PHPCompatibility as a PHP_CodeSniffer standard
composer require --dev phpcompatibility/php-compatibility

# Scan the codebase against PHP 7.4 specifically -- the NEXT
# step in an incremental 5.6 -> ... -> 8.3 migration, not the
# final destination.
vendor/bin/phpcs -p src/ \
  --standard=PHPCompatibility \
  --runtime-set testVersion 7.4
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;

// This legacy function has no documentation and the original
// author is long gone. Instead of guessing what it *should* do,
// we record what it *currently* does as our safety net.
final class LegacyPricingCharacterizationTest extends TestCase
{
    public function testKnownInputsProduceKnownOutputs(): void
    {
        // Each assertion pins down current, observed behavior --
        // not a claim that this behavior is correct.
        $this->assertSame(108.00, calculate_total(100.00, 0.08, 0));
        $this->assertSame(90.00, calculate_total(100.00, 0.00, 10));
        $this->assertSame(97.20, calculate_total(100.00, 0.08, 10));
        $this->assertSame(0.00, calculate_total(0.00, 0.08, 50));
    }
}
# Step 1: preview what a dependency upgrade would change, without installing
composer update --dry-run

# Step 2: perform the dependency upgrade as its own, separately committed step
composer update
git add composer.lock
git commit -m "Upgrade dependencies for PHP 8.1 compatibility"

# Step 3: apply and commit code-level compatibility fixes separately
# (e.g. renaming old-style constructors, fixing PHPStan findings)
git add src/
git commit -m "Fix PHP 8.1 compatibility issues found by PHPCompatibility"

Migrations at Scale

Large, long-lived PHP applications -- the kind that have been in continuous production for a decade or more -- are typically migrated by dedicated small teams working through the codebase one PHP version, one PHPCompatibility report, and one strangler-fig module at a time over many months, precisely because the alternative (a single risky big-bang rewrite or version jump) has a track record of turning into a multi-year project that never quite finishes while the business keeps needing the old system to keep working in the meantime.

Jumping multiple major versions at once compounds years of breaking changes into one undebuggable pile of failures with no safe intermediate rollback point. Incremental, one-version-at-a-time upgrades keep each step small, testable, and reversible.

// Rewrite the entire legacy application from scratch before releasing anything new

// Use the strangler fig pattern: migrate one bounded feature at a time behind a stable routing layer

Big-bang rewrites routinely run far over their original estimate while the legacy system remains a moving target that still needs bug fixes, and the eventual all-at-once cutover risks the entire application in one high-stakes event. Incremental replacement keeps risk contained and produces continuous visible progress.

// Pointing PHPStan at a legacy codebase for the first time and demanding a zero-error report before starting the migration

// Generate a PHPStan baseline to suppress existing findings, then require only NEW code to stay clean

A first run against untouched legacy code typically produces thousands of findings, most in code nobody currently plans to change -- a baseline lets the migration proceed immediately while still preventing new problems, with historical debt paid down gradually instead of blocking all progress upfront.

Why is jumping directly from PHP 5.6 to PHP 8.3 considered risky compared to upgrading one version at a time? A. PHP does not allow skipping versions technically B. It compounds every breaking change across many releases into one undifferentiated set of failures with no safe intermediate point C. Composer refuses to install packages if you skip a version D. It is actually the recommended, faster approach for most teams

B. It compounds every breaking change across many releases into one undifferentiated set of failures with no safe intermediate point — Each version's migration guide documents changes relative to the immediately prior version. Skipping straight to the newest version means debugging years of accumulated breaking changes at once, with no smaller, verified checkpoint to fall back to if something goes wrong.

What is a 'characterization test' used for in a legacy migration with no existing test suite? A. It verifies the code matches a formal specification document B. It records the code's current, observed behavior as a baseline, without claiming that behavior is correct C. It replaces the need for any manual code review D. It is only used to test brand-new code, never legacy code

B. It records the code's current, observed behavior as a baseline, without claiming that behavior is correct — Characterization tests capture what legacy code currently does for representative inputs, treating existing behavior as the specification. This gives a concrete signal if behavior changes during migration, without requiring anyone to first reverse-engineer the 'correct' intended behavior.

What is the main purpose of a static analysis 'baseline' file when first running PHPStan or Psalm against a legacy codebase? A. It permanently disables all future error checking B. It suppresses currently-existing findings so only new issues in new or changed code are reported going forward C. It automatically fixes every issue found D. It is required before the tool can run at all

B. It suppresses currently-existing findings so only new issues in new or changed code are reported going forward — A baseline records the current state of findings and suppresses them, letting a team start enforcing static analysis on new work immediately without being blocked by the full backlog of pre-existing issues in code nobody is currently touching.

What does the strangler fig pattern propose as an alternative to a big-bang rewrite? A. Freezing the legacy system entirely and building the replacement in total isolation before any cutover B. Gradually replacing pieces of the legacy system behind a stable interface, redirecting traffic piece by piece as each part is verified C. Deleting the legacy codebase immediately and writing documentation from memory D. Running the old and new systems permanently in parallel forever with no plan to retire either

B. Gradually replacing pieces of the legacy system behind a stable interface, redirecting traffic piece by piece as each part is verified — The strangler fig pattern migrates one bounded piece of functionality at a time behind a stable interface, redirecting only that piece's traffic to the new implementation once verified -- keeping risk contained to one piece at a time instead of the whole application at once.

You've inherited a PHP 5.6 application with zero automated tests that needs to reach PHP 8.2. Outline, in order, the first five concrete actions you would take before writing any migration code, and explain why each one comes before the next.

1) Run PHPCompatibility targeting PHP 7.0 (the very next hop, not 8.2) to get a concrete list of what breaks first. 2) Write characterization tests around the highest-risk, least-understood business logic, since there is no existing test suite to rely on. 3) Run composer outdated and check each dependency's own PHP support before touching the runtime. 4) Fix the PHPCompatibility findings for PHP 7.0 specifically, on the still-running PHP 5.6 environment, verifying with the new characterization tests as you go. 5) Only then perform the actual PHP 7.0 upgrade, re-run the full test suite, and repeat this whole cycle for 7.1, and so on -- never skipping ahead to 8.2 directly.

Start with understanding risk and establishing a safety net before touching any code; only after that does actual code-level compatibility work begin.