Every Symfony monolith I have been called into has the same three symptoms. Deployments take longer than they used to. The test suite has a handful of files nobody runs locally anymore. And somebody, usually on the board, has recently said the word rewrite.
A rewrite is almost always the wrong answer. Not because rewrites never succeed (they sometimes do), but because they swap a concrete problem you understand for an abstract one you do not. You know the monolith is slow to deploy. You do not know whether the greenfield system will be production-ready in eighteen months, whether your best engineers will stay for the transition, or whether the shape of the business will be the same when you finish.
The Strangler Fig pattern, named by Martin Fowler after a plant that slowly envelops its host, is the answer that survives contact with reality. You leave the monolith running. You route specific capabilities to new code, one at a time, behind the same URLs. Over months, the new system grows. Over more months, the old system shrinks. At some point the host dies and the fig is what is left.
This essay is about how to actually do that inside a Symfony codebase. Not the diagram version. The version where you have a 2016-era src/AppBundle/, a services.yml that still uses .yml, and a Jenkins pipeline nobody fully remembers.
What the Strangler Fig actually is
The pattern has three moving parts:
- A routing seam. A single place in front of the monolith where you can decide, per request, whether to let the old code handle it or route it to new code. This is usually HTTP-level, sometimes controller-level.
- Extracted capabilities. Each capability you peel off (billing, search, notifications, an admin module) becomes either a service, a bounded context in the same repo, or a separate application, depending on where the seams of ownership are.
- A shrinking plan. The only way the pattern pays off is if the old code actually gets deleted. Most failed Strangler Fig projects fail here, not at the extraction.
The reason people miss the pattern is that they focus on the middle step, extracting a service, and skip the routing seam and the deletion plan. Both of those are where the value lives.
Why this pattern fits Symfony specifically
Symfony has two properties that make the Strangler Fig unusually tractable compared to, say, a Rails or Django monolith:
- HttpKernel is composable. You can wrap the existing kernel, intercept requests, and dispatch to a different kernel or a different handler without rewriting any of the controllers it already knows about. The
HttpKernelInterfaceis the seam. - The DI container is explicit. In a large Symfony app you already have an inventory, whether you know it or not: services.yaml files,
#[AutoconfigureTag]attributes, compiler passes. That inventory tells you the real coupling map, which is more honest than any architecture diagram.
What Symfony does not give you for free is a clean split of persistence. Doctrine’s EntityManager is shared across your entire app by default. If your legacy Order entity has a bidirectional relationship with Customer, Invoice, Subscription, and three enum-backed value objects, that object graph is the real monolith, not the code.
That is where most of the Strangler Fig work actually happens. It is why the pattern takes months, not weeks.
The five moves that make it work
Over a handful of these projects I have converged on the same five moves, in roughly this order. Skipping any of them is where projects derail.
1. Plant the routing seam before extracting anything
Before you touch a single controller, add a layer in front of the kernel that can route requests to either the legacy app or new code. In Symfony, this is a kernel request listener that subscribes with very high priority.
#[AsEventListener(event: KernelEvents::REQUEST, priority: 512)]
final readonly class LegacyRouter
{
public function __construct(
private RouteMatcherInterface $newApp,
private LoggerInterface $logger,
) {}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
if (!$this->newApp->matches($event->getRequest()->getPathInfo())) {
return;
}
$this->logger->info('Routing {path} to new app', [
'path' => $event->getRequest()->getPathInfo(),
]);
// Dispatch to new handler, set response on the event.
}
}
The seam does not need to do anything yet. It needs to exist, be covered by tests, and be deployed to production. Once it is there, you have changed the question from “when do we rewrite?” to “which path do we migrate next?” That is a much better question.
2. Make authentication and session shared, not migrated
The second move is the one that saves the most time. Do not try to migrate authentication early. Keep whatever the legacy system uses (Symfony’s session, a custom cookie, a session table in the database) and make the new code read it.
The failure mode here is almost universal. Teams decide the new code “deserves” a proper JWT-based auth layer from day one. Eighteen weeks later they are still reconciling three user tables, a user_sessions table nobody dares drop, and a password reset flow that goes through both apps. Meanwhile no customer-visible work has shipped.
The correct sequence is: run on the legacy session for as long as the legacy app exists, then replace the auth layer when the old app is small enough to be a rounding error.
3. Define data ownership, and enforce it at the ORM
This is the move that kills the most projects. Even after you have extracted a capability into a separate service or bundle, if it still reads and writes the same Doctrine entities as the legacy code, you have done nothing. You have just added a new caller to a shared database.
The practical fix in Symfony is to give each new bounded context its own entity manager, even if it points to the same database. Use the doctrine.orm.entity_managers configuration to define a second manager scoped to only its entities, and mark the legacy manager as forbidden inside the new context.
doctrine:
orm:
entity_managers:
legacy:
mappings:
Legacy: ~
billing:
mappings:
Billing: ~
Then enforce a lint rule (Deptrac is ideal for this) that says the Billing\ namespace cannot reference Legacy\. The boundary has to be visible to the compiler, not just to the wiki.
Once the lint rule is in place, you will discover things you did not know. That “simple” invoice PDF generator reaches into User, Address, TaxProfile, and a legacy Settings singleton. That reach is the real extraction work. The rewrite, once the dependencies are untangled, is usually the easy part.
4. Use a messaging bus for cross-context communication
Once you have more than one context and they need to talk to each other, resist the urge to do it synchronously by injecting services across the boundary. Use Symfony Messenger.
A billing context that needs to know when a user is deactivated should not call UserService::deactivate(). It should subscribe to a UserDeactivated message that the user context publishes. The transport can start as sync://. You do not need a real queue yet. What you need is the shape of the communication to be message-based, so when you do split the apps, nothing downstream changes.
This is the part of the pattern that most reviewers find counterintuitive. “But we are in the same process, why are we bothering with async?” Because the point of the Strangler Fig is to make the split cheap later. Every synchronous cross-context call you add now is a string you will have to cut then, usually at the worst possible moment.
5. Ship feature flags before you ship the extracted code
The last move is operational, not architectural. Every new capability should go live behind a feature flag you can toggle per-user, per-tenant, or per-percentage. Ship the new code with the flag off. Turn it on for your internal accounts. Then for 1% of traffic. Then 10%. Then 100%. Then, and only then, delete the legacy code path.
Symfony has no canonical feature-flag library: growthbook/growthbook, flagception/flagception-bundle, or a rolled-your-own service backed by environment variables all work. The library does not matter. The discipline matters: the flag is the handoff, not the deployment.
Traps that kill Strangler Fig projects
I have watched this pattern fail more often than I have watched it succeed, and the failures cluster:
- Extracting the wrong thing first. The team picks the most technically interesting module, usually search or a reporting engine, and three months in discovers nobody cares. Pick something with visible customer pain and clear business value. The executive sponsor needs to see the monolith actually shrinking.
- No deletion budget. Engineering reserves 40% of sprint capacity for extraction and 0% for deletion. The old code sits there forever, a parallel universe that now also needs maintenance. The rule I enforce: no extraction PR gets merged without an accompanying deletion plan with a date.
- Mocking the seam. The routing layer exists in staging, but in production it is bypassed because “the load balancer already does that.” You now have two routing layers, neither of which is canonical, and every incident requires understanding both.
- Entity graph migration by attrition. The new context “owns”
Invoice, but the legacy app still readsinvoice.customer_iddirectly in six places. Every quarter someone adds a seventh. The boundary has to be enforced in code, not by policy. - Quiet drift. The new code gets a linter, strict typing, PHPStan level 9, and tests. The legacy code does not. Over two years the gap widens to the point where nobody is willing to touch the old code at all, which means it is also impossible to delete. Hold both codebases to the same bar, or be explicit that the legacy code is in hospice care and nobody edits it.
How to measure progress
You cannot strangle what you cannot count. I insist on three numbers being visible in a dashboard from week one:
- Percent of production requests handled by new code. Measured at the routing seam, bucketed by URL pattern. This number should only go up.
- Lines of legacy code. Run
clocon the legacy namespace weekly. Celebrate when it drops. Investigate when it rises. - Number of cross-boundary calls. The Deptrac violation count. This should trend to zero.
If any of the three is flat for two consecutive months, something is wrong. Usually it means the team is doing extraction work that does not touch the real dependency graph: shuffling files, renaming services, adding interfaces that still point at the same legacy classes underneath.
When NOT to use this pattern
The Strangler Fig is not always right. I would steer you away from it if:
- The monolith is under 30k lines of code and has fewer than four engineers. Just refactor it in place. The coordination overhead of parallel systems is not worth it at that scale.
- You are about to pivot the product substantially. Strangler Fig is conservative by design. It preserves existing behavior. If the behavior itself is about to change radically, a targeted rewrite of the parts that will change, with the rest left alone, is sometimes faster.
- The data model itself is broken. If the problem is that your
orderstable has 180 columns and no customer actually matches the ones the schema thinks they match, extracting services around that table does not help. Fix the data model first.
A realistic first-month plan
If I am dropped into a Symfony monolith on day one and given a month, this is what I ship:
- Week 1: Audit. Run PHPStan, Rector dry-run, Deptrac with a one-layer baseline. Produce a dependency graph that shows the real coupling. Identify the three candidate capabilities to extract first, ranked by business pain.
- Week 2: Plant the routing seam. Deploy it to production with zero routes yet attached. Add the observability (per-path request counts, legacy vs new) to a dashboard.
- Week 3: Pick the smallest candidate capability. Extract it into its own namespace with its own entity manager. Keep it behind a feature flag at 0% rollout. Write the contract tests that prove its behavior matches the legacy path byte-for-byte.
- Week 4: Ramp the flag. 1%. 10%. 50%. 100%. Then, and this is the part teams skip, delete the legacy code path. Merge the deletion PR in the same sprint the flag hits 100%.
At the end of the month you have proven the loop: pick, extract, route, delete. Every future capability follows the same loop. The project is not done, but it is now a predictable machine instead of an open-ended rewrite.
If you are looking at a Symfony monolith and weighing a rewrite against a Strangler Fig migration, my monolith modernisation engagement is built around exactly this loop. A four-week audit and seam-planting sprint, then extraction at the cadence your team can sustain, with the deletion built into every PR.
References
- StranglerFigApplication by Martin Fowler : the original write-up of the pattern for gradually replacing legacy systems.
- Patterns of Legacy Displacement by Martin Fowler, Ian Cartwright, Rob Horn, and James Lewis : a longer treatment covering event interception, legacy mimic, and incremental cutover.
- Feature Toggles (aka Feature Flags) by Pete Hodgson : the canonical taxonomy of feature flags, including release toggles for safe rollout.
- Symfony HttpKernel component docs : reference for
HttpKernelInterface, the composable request/response seam this post builds on. - Symfony multiple entity managers : official configuration for scoping Doctrine entity managers per bounded context.
- Symfony Messenger : the message bus used for cross-context communication, including the
sync://transport for in-process handlers. - Deptrac : static analysis tool for declaring and enforcing architectural layers in PHP projects.