Modernizing Legacy PHP Code Incrementally: Strangler Fig Instead of Big Bang
AI generated
<?php
8.4
PHP · Legacy Code · Refactoring · Modernization
Modernizing Legacy PHP Code Incrementally
Strangler Fig instead of a risky big bang rewrite

A grown PHP monolith can rarely be replaced in one big leap. Legacy PHP code can, however, be modernized in a controlled way if you identify seams, write characterization tests and gradually wrap the old code using the Strangler Fig pattern instead of replacing it in one risky rewrite.

18 min read Strangler Fig · Seams · Characterization Tests · Rector PHP 7.x to 8.4 · Legacy Monoliths

1. Why the big bang rewrite almost always fails

Anyone facing a large amount of legacy PHP code almost automatically thinks of a complete rewrite. The argument sounds convincing: the old code is messy, poorly tested and hard to extend, so you build it fresh with modern PHP 8.4, clean architecture and full test coverage. In practice, this approach fails surprisingly often, because during the months or years the rewrite takes, the old system keeps running, keeps receiving fixes, and the gap between the old and new system keeps growing.

The second problem is knowledge that only exists implicitly inside the old legacy PHP code: edge cases added years ago for a single customer, workarounds for long forgotten bugs in third party libraries, or business rules documented nowhere except in the code itself. A rewrite team that does not know this history inevitably reproduces regressions that only surface after go live. Incremental modernization avoids this risk because every change is small, verifiable and immediately usable in production, instead of waiting for one single big cutover moment.

The third reason is purely economic: a big bang rewrite ties up development capacity for a long time without producing new customer facing functionality during that period. Incremental modernization of legacy PHP code, by contrast, delivers value continuously, because every modernized section of code immediately benefits from better maintainability while the overall system keeps generating revenue. The following sections show concrete techniques for implementing this incremental modernization safely.

2. Writing characterization tests before the first refactor

Before changing even a single line of legacy PHP code, you need a safety net that documents current behavior, not desired behavior. A characterization test differs from a classic unit test in exactly that respect: it does not claim what is correct, it records what the code actually does today, including all its bugs and edge cases. Only once these tests are green can you start refactoring and check after every change whether observable behavior has unintentionally shifted.

In practice you start with the most frequently invoked functions and the most critical business processes, because a regression there would be the most expensive. For legacy PHP code without clean interfaces this often means writing coarse end to end tests first, running through an entire request and checking the resulting HTTP response or database state. Finer grained tests for individual functions follow only once those functions are isolated enough to be testable.


<?php

declare(strict_types=1);

// Characterization test: documents CURRENT behavior of legacy code,
// including quirks that would be bugs in a greenfield project.
final class LegacyPriceCalculatorCharacterizationTest extends PHPUnit\Framework\TestCase
{
    public function testAppliesDiscountBeforeTaxNotAfter(): void
    {
        // NOTE: This is the observed (not necessarily "correct") order.
        // Legacy calculateFinalPrice() applies discount BEFORE tax,
        // which differs from the newer pricing docs. We lock this in
        // as the baseline before touching the function.
        $calculator = new LegacyPriceCalculator();

        $result = $calculator->calculateFinalPrice(
            basePrice: 100.00,
            discountPercent: 10.0,
            taxPercent: 19.0
        );

        // 100 - 10% = 90, then + 19% tax = 107.10
        $this->assertSame(107.10, $result);
    }

    public function testNegativeDiscountIsSilentlyClampedToZero(): void
    {
        // Undocumented quirk found in the legacy code: negative
        // discounts are clamped instead of throwing. We record it
        // so a future refactor does not accidentally "fix" it and
        // break some hidden caller relying on this behavior.
        $calculator = new LegacyPriceCalculator();

        $result = $calculator->calculateFinalPrice(100.00, -5.0, 0.0);

        $this->assertSame(100.00, $result);
    }
}

It is important that characterization tests never pass moral judgment on the legacy PHP code. A test that records an odd rounding rule or an unexpected order of discount and tax is not a mistake in the test, it is exactly its purpose. Only once a product owner explicitly confirms a behavior really was wrong do you first change the test and only then the code, never both at the same time.

3. The Strangler Fig pattern for legacy PHP code

The Strangler Fig pattern, named after the strangler fig tree that slowly grows around a host tree and eventually replaces it entirely, is the central technique for modernizing legacy PHP code. Instead of replacing the old system all at once, you place a routing layer in front of it that forwards requests to either the old or the new code. New functionality and modernized areas move into the new code, while unchanged legacy PHP code continues to serve requests until it too is eventually replaced.

The decisive advantage of this approach is that a working overall system exists at every point in time. There is no single day on which old and new code must both be finished simultaneously, because both coexist in production in parallel. In a typical PHP application, this routing layer is often implemented through the web server, through central front controller logic, or through a feature flag system that decides per route or per user which code path applies.


<?php

declare(strict_types=1);

// Strangler facade: routes requests to legacy or modernized handler
// based on a route whitelist that grows as migration progresses.
final class StranglerRouter
{
    /** @var array<string, bool> */
    private array $migratedRoutes;

    public function __construct(
        private readonly LegacyOrderController $legacyController,
        private readonly ModernOrderController $modernController,
    ) {
        // Only routes listed here are served by the new code path.
        // Everything else still goes through the legacy controller.
        $this->migratedRoutes = [
            'order.create'  => true,
            'order.cancel'  => true,
            'order.refund'  => false, // not migrated yet
        ];
    }

    public function handle(string $routeName, array $request): Response
    {
        if ($this->migratedRoutes[$routeName] ?? false) {
            return $this->modernController->handle($request);
        }

        return $this->legacyController->handle($request);
    }
}

A side effect of the Strangler Fig pattern is that it socially distributes modernization pressure: each team can take on a small, clearly bounded area without having to understand the entire application. This property makes incremental modernization of legacy PHP code practical even in organizations with multiple teams and limited resources, because nobody has to shoulder a months long rewrite project alone.

4. Finding seams: where you can safely cut the code

A seam, a term from Michael Feathers's work on legacy PHP code, is a place in the code where you can change behavior without editing the code at that exact spot. In PHP, the most common seams are function calls, method calls on objects, and class construction. A direct call to a global function like mail() or a new PDO(...) buried inside business logic is not a seam, because you cannot replace it without changing the surrounding code.

The practical benefit of seams is that you can insert test doubles at them without touching the actual logic. A typical approach for legacy PHP code is to first pull hard dependencies like database connections or filesystem access behind an interface, without changing the internal implementation. This first step creates a seam where tests, and eventually the actual modernization, can later attach.

A common mistake is introducing the "perfect" architecture immediately, before any seam even exists. Instead you should take the smallest possible step: replace a global function with a method call on an injected object, without simultaneously restructuring the interface, the implementation and the caller. This discipline of only ever opening one seam at a time keeps every single change to legacy PHP code small enough to review safely in a single commit.

5. Isolating legacy boundaries with facades and adapters

Once a seam exists, you need a structure that keeps the boundary between old and new code clean. A facade bundles several related but messy calls into legacy PHP code behind a single, clear interface. An adapter translates between the old and the new interface, so new code never comes into direct contact with the quirks of the old system.

This isolation is crucial because it prevents legacy issues like inconsistent return values, global state dependencies or missing typing from spreading into the new code. All new code exclusively accesses the adapter, which internally deals with the messy legacy PHP code and exposes a clean, typed interface to the outside.


<?php

declare(strict_types=1);

// Adapter: wraps messy legacy functions behind a clean, typed interface
// so that new code never touches the legacy quirks directly.
interface CustomerRepositoryInterface
{
    public function findById(int $customerId): ?CustomerData;
}

final class LegacyCustomerRepositoryAdapter implements CustomerRepositoryInterface
{
    public function findById(int $customerId): ?CustomerData
    {
        // legacy_get_customer_row() returns an associative array
        // or FALSE on failure — never null, never an object.
        $row = legacy_get_customer_row($customerId);

        if ($row === false) {
            return null;
        }

        // Translate legacy string-typed fields into a proper value object.
        return new CustomerData(
            id: (int) $row['customer_id'],
            email: (string) $row['email_addr'],
            createdAt: new DateTimeImmutable($row['created_ts']),
        );
    }
}

The adapter is kept deliberately thin: it contains no business logic, only translation. This separation keeps modernization work on legacy PHP code measurable: you can always count how many callers still reach the old implementation through the adapter and drive that number toward zero over time.

6. Rector and PHPStan as an automated safety net

Manual refactoring of large amounts of legacy PHP code is error prone and slow. Rector automates mechanical transformations such as adding type declarations, replacing deprecated function calls or migrating to new language features, based on an AST rather than simple text replacement. This makes Rector considerably safer than search and replace, because it understands the syntactic context of every code location.

PHPStan complements Rector by checking after every automated transformation whether new type inconsistencies have appeared. For legacy PHP code you almost always start at a low level such as 1 or 2, because higher levels would immediately report hundreds of errors that paralyze rather than motivate the team. The level is then raised incrementally as the most common error classes are resolved.


<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Php80\Rector\FunctionLike\MixedTypeRector;

// rector.php — incremental, low-risk modernization of legacy code.
return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__ . '/src/Legacy',
    ]);

    // Apply changes up to PHP 8.1 sets only — conservative first pass.
    $rectorConfig->sets([
        LevelSetList::UP_TO_PHP_81,
    ]);

    // Skip files that are scheduled for a full rewrite anyway,
    // to avoid wasting review time on soon-to-be-deleted code.
    $rectorConfig->skip([
        __DIR__ . '/src/Legacy/DeprecatedModule',
    ]);
};

A proven workflow is to never run Rector directly against the main branch, but to review its suggestions in a separate branch and validate them against the characterization tests from section two. This way, modernization of legacy PHP code stays traceable, because every automated change remains individually reviewable instead of drowning in one giant, unreviewable commit.

7. Feature flags and parallel operation during migration

Feature flags let you run new and old code simultaneously in the same deployment and change which path executes without a new release. For legacy PHP code this is especially valuable for risky changes such as a new payment flow or a new price calculation, because you can first enable the new path only for a small percentage of traffic or for internal test users.

An advanced variant is shadow mode, where both the old and the new code execute, but only the result of the old code is actually served to the user. The results of both paths are compared and discrepancies are logged, without any user ever seeing a faulty result from the new, not yet fully trusted code. This approach delivers hard data during modernization of legacy PHP code about whether the new code is truly equivalent, instead of relying on test coverage alone.


<?php

declare(strict_types=1);

// Shadow mode: run both implementations, serve the legacy result,
// but log discrepancies so we can trust the new path before switching.
final class ShadowModePriceCalculator
{
    public function __construct(
        private readonly LegacyPriceCalculator $legacy,
        private readonly ModernPriceCalculator $modern,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function calculate(Order $order): float
    {
        $legacyResult = $this->legacy->calculateFinalPrice(
            $order->basePrice,
            $order->discountPercent,
            $order->taxPercent
        );

        try {
            $modernResult = $this->modern->calculate($order);

            if (abs($legacyResult - $modernResult) > 0.01) {
                $this->logger->warning('Price mismatch detected', [
                    'order_id' => $order->id,
                    'legacy'   => $legacyResult,
                    'modern'   => $modernResult,
                ]);
            }
        } catch (Throwable $e) {
            $this->logger->error('Modern calculator threw during shadow run', [
                'order_id' => $order->id,
                'exception' => $e->getMessage(),
            ]);
        }

        // Legacy result is always returned to the customer for now.
        return $legacyResult;
    }
}

8. Team organization: boy scout rule and dedicated modernization slots

Even the best technique fails if modernization of legacy PHP code has no organizational room to happen. The boy scout rule, leaving every piece of code a little cleaner than you found it, works well for small, incidental improvements, but is not enough for structural modernization that requires several days of planned work.

It has proven effective to reserve fixed capacity, roughly ten to twenty percent of every sprint, explicitly for modernization work on legacy PHP code, instead of letting it implicitly compete against feature work. Without this fixed reservation, modernization almost always loses against deadline pressure, because short term visible feature work usually gets higher priority in project management than invisible internal quality.

It is also important to make progress visible: a simple metric such as the share of code already passing PHPStan level 5 or higher, or the number of callers still going through legacy adapters, makes the progress of modernizing legacy PHP code tangible for the whole team and management, and justifies the time invested.

9. Strangler Fig, big bang and freeze and replace compared

Besides the Strangler Fig pattern, there are other strategies for dealing with legacy PHP code that fit differently depending on project size and risk tolerance. The following table compares the three most common approaches.

Strategy Risk Time to Value When suitable
Big bang rewrite Very high Only after months or years Very small applications, clearly bounded scope
Strangler Fig Low, incrementally controllable Immediate, per module Large, long running systems
Freeze and replace Medium Only once replacement is live Sunsetting systems with a fixed end date
Boy scout rule alone Low, but slow progress Continuous, very slow Complementary to Strangler Fig, not a replacement

In practice these strategies are often combined: the core of the system is modernized via Strangler Fig, truly outdated edge modules with no future are swapped via freeze and replace once a finished replacement module exists, and the boy scout rule provides continuous small improvements between the larger modernization steps on legacy PHP code.

Mironsoft

PHP modernization, legacy refactoring and Magento development

Is your legacy PHP code slowing down new features?

We analyze existing PHP code, identify seams and modernization paths, and guide the incremental migration with characterization tests, Rector and the Strangler Fig pattern, without stopping your operations.

Legacy audit

Identify seams, prioritize risk areas, create a modernization plan

Refactoring

Characterization tests, facades, adapters and automated Rector migrations

Guidance

Feature flags, shadow mode comparisons and team coaching for lasting quality

10. Summary

Modernizing legacy PHP code rarely succeeds through one single big leap, but through a chain of small, safeguarded steps. Characterization tests document existing behavior before anything is changed. Seams create the places where you can safely cut the code, and facades and adapters isolate the boundary between old and new so that legacy issues do not spread into fresh code.

The Strangler Fig pattern keeps a functioning overall system alive at every point in time, while Rector and PHPStan automate mechanical transformations and feature flags along with shadow mode comparisons minimize the risk of every single change. Without fixed organizational reservation of capacity, however, every technique remains theory, because modernizing legacy PHP code always competes with visible feature work and therefore needs an explicit, defended slot in the sprint plan.

Modernizing Legacy PHP Code — The Essentials at a Glance

Safety net first

Characterization tests document existing behavior before a single refactor takes place.

Strangler Fig instead of big bang

A routing layer directs requests to old or new code, the overall system stays operational at all times.

Seams, facades, adapters

Cleanly isolate legacy boundaries so new code areas never come into direct contact with old quirks.

Automation and organization

Rector and PHPStan automate mechanical steps, fixed sprint capacity secures long term progress.

11. FAQ: Modernizing Legacy PHP Code Incrementally

1Why does a big bang rewrite fail so often?
The old system keeps running while the gap grows. Implicit knowledge is lost, leading to regressions after go live.
2What is a characterization test?
A test documenting current behavior, including bugs, instead of judging what would be correct. The safety net before any refactor.
3What is a seam?
A place where you can change behavior without editing the code there directly, for example a method call instead of a global function.
4How does Strangler Fig work?
A routing layer decides per request between old and new code path. Old code is gradually replaced while the overall system stays operational.
5Why a facade or adapter?
They isolate the boundary between old and new so new code never directly touches legacy code quirks.
6Which PHPStan level to start with?
Usually level 1 or 2. Higher levels report hundreds of errors on untouched legacy code, the level rises incrementally.
7Feature flag vs. shadow mode?
A feature flag chooses the executed path. Shadow mode runs both in parallel, serving only the old result while logging discrepancies.
8Is the boy scout rule enough alone?
No, it fits small improvements, not structural modernization. That requires fixed sprint capacity.
9How does Rector help concretely?
Rector automates AST based code transformations, which is far safer than manual text search and replace.
10Making progress visible to management?
With metrics like the share of code at a given PHPStan level or the number of remaining legacy adapter callers.