The Strangler Pattern: A Practical Guide to Modernizing Legacy PHP Systems
Every few months a client comes to us with the same story: a PHP monolith that has become too tangled to touch. It usually got there one of two ways. Either it started as a quick MVP that never got the rewrite it needed, or it slowly accumulated "fat" over the years - layer on layer of logic piled up until the whole thing became heavy and unmovable.
The instinct is almost always to rebuild from scratch. Start fresh, do it right this time.
I have seen that fail more times than I can count. A full rewrite is a huge undertaking that rarely delivers on its promise. Old bugs come back, new ones get introduced, and while all of that happens your users get no new features for months or years. Sometimes they even lose features they had.
There is a better way, and it has been our default at Accesto for over 15 years: the Strangler Pattern.
What is the Strangler Pattern?
The Strangler Pattern is an architectural approach where you replace a legacy system incrementally by routing selected requests to a new implementation, instead of switching everything over at once. You carve out functionality piece by piece and redirect traffic to the new code, until the old system has nothing left to do.
Martin Fowler coined the term in 2004 after watching strangler figs in the rainforests of Queensland. These vines germinate in the upper branches of a host tree, send roots down around the trunk, and eventually replace the tree entirely. The host keeps living through most of the process. By the time it dies, the fig is already a self-supporting structure.
That is a good picture of a well-run migration. Your monolith keeps serving users while new service(s) quietly take over specific features. Customers use the same interface and never notice the migration underneath.
One thing to make clear up front: the Strangler Pattern does not mean "move to microservices." Going from a chaotic monolith to a modular, service-oriented, or hexagonal architecture works just as well. Not every company needs microservices, and strangling does not force that decision on you.
Strangler Pattern vs the alternatives
There are three realistic ways to modernize a legacy system. Here is how they compare on the things that actually matter to a business.
| Approach | What it is | Risk | Initial cost | Total cost |
|---|---|---|---|---|
| Full rewrite | Rebuild everything, then switch over | Very high | Very high | Medium to high |
| In-place refactoring | Change code directly inside the monolith | Low to medium | Low | Very high |
| Strangler Pattern | Run two systems side by side with controlled routing | Low | Medium | High |
One note on that last column: the rewrite figure assumes the rewrite actually ships. Running two systems in parallel genuinely does cost more than building one new system once, and I am not going to pretend otherwise. What the table cannot show is that the rewrite number is the one most likely to be wrong, because it quietly assumes an outcome that often does not arrive.
In-place refactoring has the lowest entry cost. You do not set up a parallel project, and you do not commit to a whole new codebase that becomes wasted effort if you never finish it. The catch is that total cost grows roughly with the technical debt you already carry. Past a certain amount of debt it stops being viable, both financially and technically. It is also hard to change architecture this way - the same issues tend to reappear if they were not fixed at the root - and it needs genuinely senior developers to pull off without making things worse.
The Strangler Pattern sits a little higher on initial cost because you run a parallel project alongside the original. It is also easy for inexperienced developers to make a lot of small mistakes during setup, and those mistakes tend to bite later. Setup can take a month for some teams. For us it is usually a week or two.
Business-wise, the Strangler Pattern is the most responsible option. You keep shipping features, you remove the risk of recurring bugs and missing functionality, and you move at a pace you are comfortable with. If you have to pause it, you still have a fully working product. Compare that to backing out of a full rewrite, where you are left with an old product you have neglected for a year and a half-built new one headed for the bin. With a full rewrite, regressions erode customer trust, and business requirements change out from under you mid-build.
Full rewrite can still be the right call, but usually only for very small projects.
The core building blocks
Implementing the Strangler Pattern in a PHP or web context needs a few technical pieces working together. Three of them are infrastructure you set up once and mostly forget. The fourth is where the actual work is, and it is the one I will spend the most time on.
The facade (proxy) layer
The facade - also called a proxy or, loosely, an API gateway - is the single entry point in front of your existing system, and it decides where each request goes: to the legacy app or to a new service.
My strong recommendation is to keep this layer dumb. Do not build it inside either the legacy or the new codebase - keep it as a separate thing - and beyond that, use what you already have. In most of our projects it is just Apache/nginx doing path routing. Traefik is a nice option too. If you are already behind an AWS ALB, that works as well.
A URL-based facade in nginx is about as simple as it looks:
upstream new_service { server new-app:8080; }
upstream legacy_app { server legacy:80; }
# New service takes /api/v2/*, everything else stays on legacy
location /api/v2/ {
proxy_pass http://new_service;
include proxy_headers.conf;
}
location / {
proxy_pass http://legacy_app;
include proxy_headers.conf;
}Do not skip that proxy_headers.conf - it is Host, X-Real-IP, X-Forwarded-For and X-Forwarded-Proto, and forgetting it is the most common way to break this on the first day. Your legacy app builds absolute URLs and redirects from what it thinks the request was. Put a proxy in front of it without forwarding the original host and scheme, and it starts issuing http:// links on your HTTPS site and looping on login - all without a single line of application code changing.
At the start, the facade sends (nearly) all traffic to legacy. As new components go live, you change routing rules to point specific paths at the new architecture. That is the whole mechanism.
Routing strategies
There are two routing strategies worth knowing, and one of them covers almost everything.
- URL-based routing.
/api/v2/*goes to new services, everything else to legacy. This covers about 95% of our cases and it is the one to reach for first. - Header or cookie-based routing. Feature flags or a canary header decide which system handles the request. This lets you do a phased or percentage rollout, but you now have to keep data in sync between both sides for the same feature. Useful in specific cases, not something to default to.
Keep the routing rules simple. Complex logic belongs in the services, not in the facade.
Anti-corruption layer (ACL)
The anti-corruption layer translates between the old domain model and the new one, so legacy concepts do not leak into your new Symfony or Laravel code.
In my opinion this is where migrations quietly go wrong. And it is usually not because teams skip the ACL - it is because they build something that looks like one. You spend months rebuilding a system and end up with a data model that has the same problems as the one you left, just in newer syntax. A real ACL is what stops that.
A concrete example. Say your legacy system has one big user table with mixed concerns - login, profile, billing status, all in one row. Your new bounded contexts might split that into Account, Profile, and Subscription.
Here is the translation code teams usually write for it:
// Looks like an ACL. Is not one.
new Account(
AccountId::fromLegacy((int) $legacyRow['id']),
new Email($legacyRow['email']),
AccountStatus::fromLegacyFlag((int) $legacyRow['active']),
);The mapping is correct, but fromLegacy() and fromLegacyFlag() are named constructors on your new domain objects. The old schema just moved in. Delete the legacy system a year from now and those methods are still sitting on AccountId and AccountStatus, waiting for someone to ask what a "legacy flag" was.
There is a simple tell for this: grep your new bounded context for the word legacy. Every hit outside the ACL is a leak.
The other half of the mistake is the shape. A translator that takes a $legacyRow array is a mapper, not a layer - whoever called it already went and fetched from the old database, so legacy leaked one level up anyway. An ACL is a boundary. The new code depends on a port it owns, and the adapter behind that port is the only thing in your system that knows the old tables exist:
// Port owned by the new bounded context. Legacy is not mentioned anywhere.
interface AccountRepository
{
public function findById(AccountId $id): ?Account;
}
// The ACL: the only class that knows the old `user` table exists.
final class LegacyAccountRepository implements AccountRepository
{
public function findById(AccountId $id): ?Account
{
$row = $this->legacyDb->fetchAssociative(
'SELECT id, email, active FROM user WHERE id = ?',
[$id->toString()],
);
if ($row === false) {
return null;
}
return new Account(
new AccountId($row['id']),
new Email($row['email']),
// legacy stored status as a 0/1 int. The translation lives here,
// not in the enum.
((int) $row['active']) === 1
? AccountStatus::Active
: AccountStatus::Suspended,
);
}
}The point is not the code itself, it is the discipline. Every legacy quirk gets handled behind one interface, and your domain model never has to know the old schema existed. When the migration finishes you swap in a DoctrineAccountRepository behind the same port, delete the ACL, and nothing in your domain changes. That last part is the whole test - if deleting the ACL forces you to touch your new code, it was never an ACL.
Data access strategies
Database handling is complex enough that it gets its own section further down, but the shape of the progression is:
- Shared database first. Both systems read and write the same MySQL or PostgreSQL instance. It is an antipattern long-term, but with a solid ACL it is fine for a short window. The one rule I would not break: only one side writes to a given table, never both at once. Data inconsistency and mismatched validation rules are the enemy here.
- Separate schemas, introduced gradually, with synchronization via ETL, events, or CDC tools. This data does not always need to be synced in real time. More often than you would expect, a delay is perfectly acceptable, which makes your life easier.
Operational prerequisites
Before you strangle anything, you want these in place:
- Observability. Centralized logging, metrics, and distributed tracing. We deploy Sentry early on almost every project.
- Automated tests. At a minimum, characterization tests around your critical flows.
- CI/CD with rollbacks. Safe, frequent releases of both the legacy and the new components. Always know how to roll back before you release, and be sure of every step to get back.
Without these you are flying blind during the transition, and the transition is exactly when you cannot afford to be.
When to use it, and when not to
Not every legacy system needs this pattern. Here is how we help CTOs and product owners decide.
When it is a good fit
- Large PHP projects with real accumulated technical debt
- Revenue-critical systems where downtime maps directly to lost money
- High-traffic SaaS with strict SLAs and availability requirements
- Teams that must keep shipping features during modernization
- Products on outdated frameworks - Symfony 2.x (long past end of life), old Zend Framework (now Laminas), or a hand-rolled legacy MVC
- Distributed teams, where incremental migration reduces coordination risk
When it is overkill
- Very small apps you could realistically rewrite in a few weeks
- Hard-deadline full cutovers - though a forced cutover is rarely the right constraint in the first place
- Low-traffic, low-impact apps, where your risk tolerance is simply higher
- Apps already on a modern framework without enough mess to earn back the strangler overhead. Here an ordinary iterative refactor is usually the better tool, without the parallel-system cost.
A few more factors
- Regulatory constraints. Financial or medical data, especially for EU customers, may dictate where data can flow during migration.
- Strict SLAs. The pattern's whole strength is minimizing disruption, so tight SLAs push toward it.
- Codebase complexity. The more tangled the monolith, the more incremental migration pays off.
How it works in practice
This is the high-level recipe we follow.
1. Initial assessment
Before writing any code, map the terrain:
- Review the existing code review process and CI/CD
- Deploy automation that supports rollbacks
- Identify business-critical flows and how often they run
- Document primary entry points: HTTP endpoints, CLI jobs, message queues
- Understand database dependencies and data relationships
- Assess test coverage and observability gaps, and deploy Sentry
- Talk through business goals and the risk of touching each part of the system
- The output is a technical map of the monolith.
2. Build the facade
- Decide what plays the facade role and deploy it
- Route 100% of traffic through it
- Confirm everything still works - this step should be invisible to users
Now you have the mechanism for controlled routing, with zero functional change.
3. Connect shared state (sessions and the like)
If both sides need access to the same user sessions or similar data, wire that up now. How you do it depends on your setup: read the cookie and fetch the user through an internal HTTP API, or move to JWT. It comes down to your business case and what is technically available to you.
4. Choose the first feature to strangle
Look for a module that is:
- Low risk - not core payments or auth, at least not first
- Highly isolated - few dependencies on the rest of the system
- Clearly bounded - a well-defined domain like reporting, profiles, or notifications
- Worth proving - an early win that builds confidence
In short: something small but genuinely important. A quick win you can ship and use to test the approach. We often validate the choice with a short spike, a few days to confirm the feature extracts cleanly.
5. Run the migration cycle
For each feature:
- Model the data and flows. Try not to let the old design anchor you too much. Discuss the model with the business - this is where you catch the mistakes that were baked into the legacy version.
- Implement it in the new Symfony or Laravel project.
- Route traffic to the new implementation.
- Monitor it: compare response times, error rates, and business outcomes against the old path.
- Remove the legacy code path once you are confident.
Expect 2 to 4 weeks per feature, depending on complexity.
6. Repeat and expand
Iterate feature by feature, or bounded context by bounded context. Legacy shrinks while the new architecture grows. Each round teaches you something, the team gets faster, and the migration accelerates.
7. Decommission
Once no real traffic hits the legacy app:
- Verify every business-critical flow is mapped to a new component
- Confirm data is fully migrated or archived
- Decommission the old code and database
- Reclaim the infrastructure
Hold on to the old code and a data backup for a while afterward. It costs you nothing and it is cheap insurance.
Strangling the database
The database is usually the hardest part of the whole migration. Most legacy PHP systems we work with sit on MySQL or PostgreSQL with years of schema decisions layered in. Here is how we move through it.
Phase 1: Shared database
Both legacy and new components read and write the same database. It is the path of least resistance:
- New services connect to existing tables
- Clear conventions (schema naming, migration scripts) prevent conflicts
- No synchronization to build yet
- Legacy keeps working exactly as before
This is usually called an antipattern, and fairly so. An old database carries old mistakes, and a shared database is an open invitation to carry those mistakes into your new model. That is exactly what the ACL is there to prevent, and it is hard to hold that line without experience. If you are not confident here, skip straight to separate databases. It is safer. Also: avoid writing from two systems to one table at all times!
Phase 2: Separate schemas
As the new system matures, start splitting the data:
- New services get their own schemas or databases
- Sync from the legacy store using ETL jobs (scheduled batches), database triggers (real-time but tightly coupled), or a change data capture tool like Debezium
- Event-driven updates where eventual consistency is acceptable
- Dual writing, where the application writes to both stores on every change
That last one deserves its own warning. Dual writing is the most tempting option because it needs no extra infrastructure, and the riskiest because your application now owns consistency between two databases. If you use it, budget for conflict resolution, detailed logging on both write paths, and reconciliation jobs that verify integrity on a schedule. Treat it as a temporary state with an aggressive deadline to get out of it, not a resting place.
To be clear, this is a different thing from the rule in Phase 1. There the warning was two systems writing to one table, which you should never do. Here it is one system writing to two stores during a migration window, which is risky but sometimes necessary.
This is also where consistency needs real attention in general. We add validation scripts and monitoring to catch drift before it becomes a support ticket. It is a broad topic and worth reading up on separately - the strategies here fill entire books.
Phase 3: Full separation
The target state:
- Each bounded context owns its data store
- The legacy database becomes read-only or archived
- Services talk through APIs or events, not shared tables
- The legacy database can finally be retired
Practices that apply throughout
- Run data validation scripts continuously
- Schedule backfill jobs for off-peak hours
- Do dry-run migrations in a staging environment that mirrors production volume
- Keep rollback capability at every step
- The phases above do not necessarily mean you need to go them all, and in the specified order. Mix, adjust, keep in mind which ones are risky. Understand when you just validate an idea and can cut corners, and when the solution has to be solid. Keep the new model clean ALWAYS.
Common pitfalls and how to avoid them
These are the ones that turned migrations harder than they needed to be.
Proxy bottleneck
- The problem. An under-scaled or over-engineered facade slows down every single request. You have added latency to the whole system.
- What to do. Scale the proxy horizontally from day one. Cache repetitive requests. Keep routing rules simple and push logic into services. Monitor the proxy as closely as you monitor the app.
This pitfall really only exists if you let the facade become an application. Keep it a thin layer like nginx or Traefik and it disappears - in our projects it never came up once.
Unclear domain boundaries
- The problem. Without well-defined bounded contexts, the new architecture just reproduces the monolith's complexity. You trade a tangled monolith for distributed spaghetti, which is worse.
- What to do. Invest in domain modeling before you write code. Use event storming or similar DDD techniques. Accept that boundaries will shift and plan for it. Resist the urge to "just extract that class into a service."
"Short-lived forever" states
- The problem. Temporary integrations - a fragile sync script, a "just for now" dual write - stick around for years. Debt piles up in the integration layer where nobody is looking.
- What to do. Put explicit deadlines on temporary states. Make cleanup a milestone deliverable, not a someday task. Review integration points quarterly. Budget time for removing scaffolding, not only for building it.
Team and process issues
- The problem. Product pressure pushes every new feature into the legacy app. Test coverage stays thin. Monitoring keeps getting deprioritized.
- What to do. Require new features to land in the new implementation wherever possible. Gate major releases on a minimum test coverage. Treat observability as a feature, not an afterthought. And translate the migration's benefits into business terms your stakeholders actually care about.
Stakeholder expectations
- The problem. Leadership wants immediate results while the team is running two systems at once.
- What to do. Be honest up front about the cost of parallel systems. Define clear milestones: first strangled feature, database split, legacy shutdown. Make early wins visible. Report progress as the percentage of traffic the new system handles - it is the number everyone understands.
In summary
Modernization is never simple. Legacy systems age, accumulate debt, and resist change. But the answer is rarely a dramatic full rewrite. More often it is a patient, incremental migration that keeps the business running while the technology evolves underneath it.
The Strangler Pattern has been central to our work at Accesto for more than a decade. It is not flashy and it does not promise an overnight transformation. What it delivers is lower risk, better performance, and a system that can finally change to meet the business.
If your PHP monolith has become a liability instead of an asset, this is probably the path forward. We are happy to talk through your specific situation and whether it makes sense for your product.
FAQ
How long does a Strangler Pattern migration take for a legacy PHP system?
It depends heavily on codebase size, business constraints, and team capacity. For a medium-sized SaaS product we typically see 6 to 18 months of incremental migration. But visible wins - faster endpoints, quicker feature delivery - usually show up in the first 2 to 3 months. The whole point is that you deliver value throughout, rather than waiting for a big reveal at the end.
Do we have to move to microservices to use the Strangler Pattern?
No. Microservices are one possible destination, not a requirement. The pattern works just as well going from a tangled monolith to a modular monolith or a small set of well-defined services. We often prefer starting with a modular monolith in Symfony or Laravel before even considering finer-grained services. It keeps operational complexity manageable and lets the architecture evolve from real needs rather than theory.
Can we apply it with limited test coverage in the legacy app?
Poor coverage is common in older PHP systems and it does raise the risk, but it does not rule out the pattern. We add characterization tests around the critical business flows before redirecting any traffic. Those tests capture existing behavior without requiring deep knowledge of the implementation. Combined with better monitoring and logging, that is usually enough safety to proceed while you improve coverage in the areas that matter most.
What infrastructure changes does a strangler approach need?
Usually some subset of:
- A reverse proxy or API gateway (nginx, Traefik, or a cloud-native option)
- Separate environments for the new services, even if that is just extra containers
- Centralized logging and monitoring
- Shared session storage such as Redis, if both sides need it during the transition. JWT lets you skip this in many cases
Many clients use this as the moment to containerize with Docker and adopt Kubernetes or ECS, but that is optional and can be phased independently from the application migration.
How do we know when it is safe to decommission the legacy system?
When all business-critical flows are mapped to new components, no production traffic hits the legacy endpoints, and data has been fully migrated or archived. Keep the old code and a backup around for a while after that, just in case.



