DEV Community

Cover image for The Strangler Pattern in PHP: Shipping Two Architectures at Once for a Year
Gabriel Anhaia
Gabriel Anhaia

Posted on

The Strangler Pattern in PHP: Shipping Two Architectures at Once for a Year


Somewhere on your team there is a Notion doc titled "Q3 Architecture Migration." It has a Gantt chart. It has a freeze week in October with a 60% confidence marker nobody believes.

You have two choices. Run that freeze week, learn two weeks was always going to be a month, and ship nothing the quarter the migration lands. Or accept that for the next twelve months your PHP codebase will hold two architectures at once, in the same composer.json, behind the same nginx, sharing the same orders table. Design for that explicitly.

The second option is the strangler pattern. Martin Fowler named it in 2004 after the fig tree that grows around its host and replaces it from the outside in. The host keeps living the whole time the fig is winning. Eventually somebody deletes a file called LegacyOrderProcessor.php and nothing happens.

This post is about that year. Not the architecture you arrive at. The middle. Routing, container, tests, deploys, and the part nobody writes down: how it feels to review pull requests when half the codebase is Eloquent facades and the other half is readonly value objects.

A fig tree growing around an old PHP application, two architectures coexisting

The seam: branch by abstraction

The strangler pattern is not "write the new version next to the old version." That is how you get a haunted house: two code paths that both believe they own the order lifecycle, disagreeing about partial refunds at 2am.

The seam is an interface. One interface, named after the verb your endpoint performs, with two implementations behind it. Callers don't know which one ran.

Here is the seam for a typical Laravel order endpoint:

<?php

declare(strict_types=1);

namespace App\Order;

interface OrderProcessor
{
    public function process(OrderRequest $request): OrderResult;
}

final readonly class OrderRequest
{
    public function __construct(
        public string $customerId,
        public array $items,
        public ?string $coupon = null,
    ) {}
}

final readonly class OrderResult
{
    public function __construct(
        public string $orderId,
        public int $totalCents,
        public string $status,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The legacy implementation lives in app/Order/Legacy/ and reaches into the old fat controller without going through HTTP:

<?php

declare(strict_types=1);

namespace App\Order\Legacy;

use App\Http\Controllers\OrderController;
use App\Order\OrderProcessor;
use App\Order\OrderRequest;
use App\Order\OrderResult;
use Illuminate\Http\Request;

final class LegacyOrderProcessor implements OrderProcessor
{
    public function __construct(
        private readonly OrderController $legacyController,
    ) {}

    public function process(OrderRequest $request): OrderResult
    {
        $legacyRequest = Request::create('/orders', 'POST', [
            'customer_id' => $request->customerId,
            'items'       => $request->items,
            'coupon'      => $request->coupon,
        ]);

        $response = $this->legacyController->store($legacyRequest);
        $payload  = json_decode($response->getContent(), true);

        if ($response->getStatusCode() >= 400) {
            throw new LegacyOrderFailed(
                $payload['error'] ?? 'unknown',
                $response->getStatusCode(),
            );
        }

        return new OrderResult(
            orderId: (string) $payload['id'],
            totalCents: (int) round($payload['total'] * 100),
            status: $payload['status'],
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The wrapper is dull on purpose. Every legacy quirk keeps firing inside OrderController::store: global helpers, model events, cache tags. The wrapper translates at the seam.

The new implementation lives in src/Order/: a use case, a repository port, a domain entity, an outbound HTTP port for the payment gateway. It satisfies the same interface and returns the same OrderResult.

Now the container picks which one runs.

The container: feature flags at the binding

Laravel's container resolves bindings at runtime. That is the lever. You ask the flag service "is this request supposed to hit the new code?" and bind accordingly.

<?php

declare(strict_types=1);

namespace App\Providers;

use App\Order\Legacy\LegacyOrderProcessor;
use App\Order\NewOrderProcessor;
use App\Order\OrderProcessor;
use App\Support\Flags;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Support\ServiceProvider;

final class OrderProcessorProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(
            OrderProcessor::class,
            function (Application $app): OrderProcessor {
                $flags = $app->make(Flags::class);

                if ($flags->isActive('order.new-processor')) {
                    return $app->make(NewOrderProcessor::class);
                }

                return $app->make(LegacyOrderProcessor::class);
            },
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Flags::isActive reads from Pennant, LaunchDarkly, a Redis hash, or a database table, whichever your team already has. The important property is that it is a runtime decision and a runtime decision reverts in seconds.

You start the flag at one percent. The new path runs for one percent of order submissions. You watch the dashboards. Error rate steady. Latency steady. Move to five. Then twenty-five. Then a hundred. If anything misbehaves, you flip the flag back to zero and the entire user base is on the legacy path in under a minute. No deploy. No rollback PR. The flag is the rollback.

In Symfony the same pattern lives in services.yaml or a compiler pass:

<?php

declare(strict_types=1);

namespace App\Order;

use App\Order\Legacy\LegacyOrderProcessor;
use App\Support\Flags;

final readonly class OrderProcessorFactory
{
    public function __construct(
        private Flags $flags,
        private LegacyOrderProcessor $legacy,
        private NewOrderProcessor $new,
    ) {}

    public function create(): OrderProcessor
    {
        return $this->flags->isActive('order.new-processor')
            ? $this->new
            : $this->legacy;
    }
}
Enter fullscreen mode Exit fullscreen mode

Whichever framework you are on, the constraint is the same: callers depend on the interface, never on the concrete class. If a controller types LegacyOrderProcessor anywhere, the seam has leaked, and the next person who refactors that controller will quietly delete the flag check.

The route table during strangulation

People assume strangling means you also strangle the routes — that the new endpoint is POST /v2/orders and the old one is POST /orders. Usually not. The new code path serves the same route. The flag picks behavior. The URL stays.

<?php

declare(strict_types=1);

use App\Http\Controllers\OrderController;

Route::post('/orders', [OrderController::class, 'store']);
Enter fullscreen mode Exit fullscreen mode

OrderController::store is now a four-line method that resolves the OrderProcessor interface, builds a request DTO, calls process, and renders. The legacy controller logic still exists, but it lives inside LegacyOrderProcessor::process, reachable only through the interface. The route stayed the same. The mobile app did not ship a new version. That is the whole win.

The version-the-URL trick is for the case where the new endpoint genuinely has a different request shape. That is a different problem on top of the strangler pattern, not the same problem.

Contract tests: the only thing that lets you sleep

Two implementations satisfy the same interface. That is a claim. Claims need to be checked, or they are wishes.

A contract test runs the same set of assertions against every implementation that claims to satisfy the contract. PHPUnit and Pest both make this trivial with parameterized providers.

<?php

declare(strict_types=1);

namespace Tests\Order;

use App\Order\Legacy\LegacyOrderProcessor;
use App\Order\NewOrderProcessor;
use App\Order\OrderProcessor;
use App\Order\OrderRequest;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class OrderProcessorContractTest extends TestCase
{
    public static function implementations(): array
    {
        return [
            'legacy' => [fn () => self::makeLegacy()],
            'new'    => [fn () => self::makeNew()],
        ];
    }

    #[Test]
    #[DataProvider('implementations')]
    public function it_charges_the_sum_of_line_items(callable $factory): void
    {
        /** @var OrderProcessor $processor */
        $processor = $factory();

        $result = $processor->process(new OrderRequest(
            customerId: 'cust-1',
            items: [
                ['sku' => 'A', 'qty' => 2, 'price_cents' => 1500],
                ['sku' => 'B', 'qty' => 1, 'price_cents' => 700],
            ],
        ));

        self::assertSame(3700, $result->totalCents);
        self::assertSame('confirmed', $result->status);
    }

    #[Test]
    #[DataProvider('implementations')]
    public function it_rejects_an_expired_coupon(callable $factory): void
    {
        // ...same assertions, both implementations.
    }
}
Enter fullscreen mode Exit fullscreen mode

If the legacy implementation returns Money and the new one returns an int representing cents, that is a contract violation, and you want to find it in CI on a Tuesday morning. Not in a Datadog alert at 2am.

The contract test suite is the gate. Until both implementations pass every assertion, the flag stays at zero. Once they both pass, the flag becomes a business decision instead of a risk.

The shadow-write reconciliation job

Tests catch the bugs you know about. Production catches the ones you do not. For high-value endpoints, run both implementations in production for a stretch and compare the outputs.

<?php

declare(strict_types=1);

namespace App\Order\Shadow;

use App\Order\OrderProcessor;
use App\Order\OrderRequest;
use App\Order\OrderResult;
use Psr\Log\LoggerInterface;
use Throwable;

final readonly class ShadowOrderProcessor implements OrderProcessor
{
    public function __construct(
        private OrderProcessor $primary,
        private OrderProcessor $shadow,
        private LoggerInterface $log,
    ) {}

    public function process(OrderRequest $request): OrderResult
    {
        $result = $this->primary->process($request);

        try {
            $shadowResult = $this->shadow->process($request);

            if ($this->differs($result, $shadowResult)) {
                $this->log->warning('order.shadow.mismatch', [
                    'request_id'   => $request->customerId,
                    'primary'      => $result,
                    'shadow'       => $shadowResult,
                ]);
            }
        } catch (Throwable $e) {
            $this->log->warning('order.shadow.failed', [
                'error' => $e->getMessage(),
            ]);
        }

        return $result;
    }

    private function differs(OrderResult $a, OrderResult $b): bool
    {
        return $a->totalCents !== $b->totalCents
            || $a->status !== $b->status;
    }
}
Enter fullscreen mode Exit fullscreen mode

Primary serves the user. Shadow runs in a try/catch and never affects the response. The log channel gets every mismatch with both payloads. You read that channel for a week before flipping the flag from one to five percent.

Shadow writes only work when the operation is idempotent — reads, calculations, validation. For state-changing operations (an order that actually charges a card) you do not shadow-run against real payments. You shadow-run against a test processor, or reconcile in a background job that compares what each path would have done given the same input.

Rollback safety: the part you test in staging

Skip rollback and you do not have a migration. You have a promotion that survived.

Rollback has four cleanly separated levers, cheapest to most expensive:

  1. Flag flip back to zero. Seconds. No deploy. Recovers from any behavioral bug in the new path.
  2. Container binding swap to LegacyOrderProcessor unconditionally. One-line PR, normal deploy. Recovers from a bug in the flag service itself.
  3. Revert the new code path's PRs. Hours. Recovers from a bug nobody can isolate.
  4. Database migration rollback. Days, sometimes never.

Lever four is the one that ends careers, because teams write migrations that lock the new path in. The strangler pattern's hardest constraint: the schema has to be readable and writable by both implementations for the entire migration year. New columns are nullable. New tables are additive. No DROP COLUMN. No RENAME TABLE. Not even if it is "obviously safe."

When the migration is done and the legacy code is deleted, that is when you run the cleanup migration. Not before. The first time you skip this rule will be the time the flag flip fails to roll back because the new path wrote to a column the old path doesn't know exists.

The social and political part

For six to eighteen months, two coding styles live in the same repository. Half the code uses Eloquent models, public attributes, and global helpers. The other half uses readonly value objects, ports, and use case classes. Both ship in the same deploy.

This is not tidy. You will get PR comments asking why some files look like one thing and other files look like another, and the answer will always be "the migration isn't done yet." Print that on a sticky note.

The way to survive the awkward middle is to be loud about it. The README gets a section called "How code is organized during the migration." Two paragraphs: app/Legacy/ is the old code, no new features go there; src/ is the new code and every new feature lives there. Tell every new hire the same thing on day one.

The worst outcome is a codebase that almost committed to Clean Architecture and silently allowed Eloquent models to leak into use cases because nobody pushed back during a Friday afternoon PR review. The migration has to be visible. It has to be in standup. It has to have a tracked number — endpoints remaining, percent of traffic on the new path, anything quantitative.

Hardest social pattern: an engineer joins, writes a feature in app/Legacy/ because that is what they pattern-matched against, and the PR gets approved because the reviewer was busy. Now there is new legacy code. The migration target moved further away. One occurrence is forgivable. A second one means the "no new code in legacy" rule is rhetoric, not policy.

The migration trajectory: legacy traffic shrinking as the new path takes over endpoint by endpoint

What you do not do

A few failure modes show up in almost every long migration:

  • The big rewrite week. "One focused week" becomes two becomes a month. Product comes back and asks where their feature is. The strangler pattern exists so you never have to do this. Migrate one endpoint per sprint, in the normal flow of work.
  • Clean code without the seam. A new PlaceOrder use case exists in src/. The legacy OrderController@store also exists, also gets traffic. Both write to the same orders table. The first partial-refund disagreement is a production incident with two stack traces from two architectures. The interface is not optional.
  • Letting the legacy code drift. Every week of new legacy behavior is a week the new version falls further behind. Pick a freeze policy on day one and write it down.
  • No exit criteria. "We will migrate the whole thing eventually" is not a plan. The plan is a list of endpoints sorted by how much engineering time they consumed last quarter, and a target rate. Without the list, the migration becomes ambient. Ambient migrations die.

The day it ends

The moment the legacy code's last call site is deleted, something quiet happens. Nothing breaks. No deploy panic. No celebration email. Dead code is the easiest code to remove.

Knowing the call site is actually the last one is the part that wants tooling, not vibes. PHPStan with --level=8 plus a dead-code extension like shipmonk/dead-code-detector catches methods nobody references. Observability on the feature flag's old-path branch (request count, last-seen timestamp) catches what static analysis cannot, because dynamic dispatch and string-based service lookups defeat it. When both say zero for two weeks, the legacy code is dead.

You delete the file. You delete the legacy adapter. You delete the feature flag if it has no other purpose. The interface either stays as the only path and gets renamed to drop the New prefix, or it disappears too.

The README gets edited. The "two coding styles" section comes out. The new style is the only style. The migration is over.

You write a short Slack message to the engineering channel saying the legacy code is gone, and you go back to the next endpoint, because you probably have three more services in the same shape.


If this was useful

The strangler pattern is the migration playbook for any PHP application earning revenue while it changes shape. Decoupled PHP walks the full architecture this post points at — Ports, Adapters, Use Cases, the Dependency Rule, transactions across layers, and two complete legacy-to-clean migrations (Laravel and Symfony) in production-honest detail. If your team is staring at a Q3 Notion doc with a freeze week in it, the book is the alternative.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)