Using the Strangler Pattern to Migrate PHP to Go
How to route traffic, draw boundaries and move data ownership so a PHP monolith is replaced piece by piece instead of rewritten in one jump.
The strangler pattern is the only migration approach I trust for a PHP application that is already making money. It does not ask you to freeze the product, branch for six months, and then hope the cutover weekend goes well. It asks you to put a routing layer in front of the existing system, move one behaviour at a time behind that router, and delete the old code once the new path has proven itself in production. Applied to a PHP monolith and Go services, the strangler pattern PHP teams end up with is less a rewrite than a slow, reversible transfer of responsibility.
The catch is that the pattern is easy to describe and easy to do badly. Most failed migrations I have looked at did technically follow the shape, new services, new repository, new deployment pipeline, but never actually strangled anything. They added a second system next to the first one and doubled the maintenance surface. The difference between the two outcomes is almost entirely in the details below: where the routing happens, how boundaries are drawn, who owns the data, and whether anyone is allowed to delete PHP code.
Where the name comes from
Martin Fowler named the pattern after the strangler fig, a family of tropical plants that germinate in the canopy of a host tree, send roots down around the trunk, and gradually take over the structural role of the tree they grew on. Eventually the host dies and rots away, and the fig stands as a hollow lattice in the same shape.
That image is the useful part. The new system grows around the old one and takes over its functions incrementally, using the old system’s shape as scaffolding. There is no moment where the tree is cut down and replaced. And critically, the host eventually disappears, a strangler fig that grows next to a tree forever is just two trees.
The routing layer is the whole pattern
Everything else in an incremental PHP migration is negotiable. The router is not. You need a single place where a request can be sent either to the PHP monolith or to a Go service, changed at runtime, without a deploy of either.
client
|
v
+--------------+
| edge router |
+--------------+
| |
| | /pricing/* <-- migrated
| v
| +-------------+
| | Go service |
| +-------------+
v
+----------------+
| PHP monolith | everything else
+----------------+
In practice the router is something you probably already run: Nginx or Caddy in front of PHP-FPM, an ALB or CloudFront behaviour, a Kubernetes ingress, Traefik, or an API gateway. Do not write a custom PHP front controller that proxies to Go. It works for a demo and then becomes the bottleneck and single point of failure for the entire migration, and it keeps your new services dependent on the runtime you are trying to retire.
Three kinds of split are worth knowing:
| Split | Mechanism | Good for |
|---|---|---|
| Path | /api/pricing/* goes to Go |
Whole endpoints, clean boundaries |
| Header / cookie | X-Migration: go or a session flag |
Internal staff first, then cohorts |
| Percentage | Weighted upstreams | Gradual exposure of one endpoint |
Path-based splits are the ones to aim for, because they make the boundary visible in the URL and therefore visible to everyone on the team. Header-based splits are how you test in production safely: your own team gets the Go path first, then a small set of accounts, then everyone. Percentage splits are the canary mechanism, discussed further down.
One rule about the router: the client must not know. If the frontend needs to call a different host for migrated endpoints, you have leaked your internal migration into your public contract, and you will not be able to move a boundary again without a coordinated release. Same origin, same paths, different upstream.
Draw boundaries around behaviour, not tables
The most common structural mistake is to pick the first slice by looking at the database schema, “we will extract the orders tables”, instead of looking at what the system does. Tables are shared by many behaviours. A boundary drawn around a table forces you to move every feature that touches it at once, which is a rewrite with extra steps.
Pick a behaviour with a narrow interface and a clear trigger instead. Good first candidates usually look like this:
- A read-heavy endpoint whose output depends on a small number of inputs, pricing calculation, search, a public product feed, a report.
- An asynchronous job that consumes from a queue and writes to one place: image processing, exports, notification fan-out, webhook delivery.
- An integration boundary: the code that talks to a payment provider, a carrier API, a tax service.
- A new feature that would otherwise be built inside the monolith. This is the cheapest slice you will ever get, because there is nothing to migrate.
Bad first candidates are anything sitting in the middle of the domain, user identity, the core checkout write path, permissions, because everything else depends on it and you will spend the whole engagement on plumbing before you can show a result.
If you want the wider decision framework for which parts of a PHP codebase deserve extraction at all, and which should be improved in place, I wrote a longer piece on how to plan a PHP to Go migration end to end. It also covers the boundary question in the context of a full application rather than a single slice.
Data ownership is where migrations actually fail
Routing is a weekend of work. Data ownership is the hard part, and it is worth being strict about one rule: exactly one service writes a given table. Not “both write it but only in different cases.” One.
When you extract a behaviour, you have four realistic options for its data.
Shared database, PHP still owns writes. The Go service reads the same tables (ideally from a replica) and writes nothing. This is the cheapest starting point and perfectly respectable for read-only slices. The cost is a shared schema: a migration in the monolith can break the Go service silently. Mitigate it with a database view or a small set of read-only queries you treat as a contract, and put the Go service’s queries under test.
Go owns the writes, PHP reads. The inverse. Now the monolith reads a table it no longer writes, which is safe, or calls the Go service over HTTP, which is safer but adds a runtime dependency from old to new. Both are fine; pick based on how tolerant that PHP code path is to latency and failure.
Separate storage with change data capture. The Go service gets its own database, and changes flow from the monolith’s database into it through the write-ahead log, Debezium, pg_logical, or MySQL binlog tailing. CDC is genuinely useful because it does not require touching the monolith’s write paths at all, which matters when that code is old and frightening. The cost is real operational complexity: schema evolution, snapshotting, replication lag you must design around, and a new class of incident.
Dual writes. The application writes to both the old and new stores in the same request. This is the option that looks easiest and is the most dangerous, so it deserves its own note.
Why dual writes bite
A dual write is a distributed transaction implemented with hope. The first write succeeds, the second one times out, and now two systems disagree with no record of which is right. It will not happen in testing, because in testing both stores are healthy. It will happen in production during a deploy or a network blip, and it will happen to the record a customer is currently looking at.
Choosing between shared schema, CDC and an outbox is the decision that determines how long the migration takes, and it is the one most teams make by default rather than deliberately. It is also the main thing I look at when I do a migration readiness review of a PHP system: the routing is rarely the blocker, the write paths almost always are.
If you cannot avoid dual writes, at least make them recoverable: write to one store as the source of truth, emit an event or an outbox row in the same transaction, and have a background process apply it to the second store with retries. That is the transactional outbox pattern, and it turns “two writes that might disagree” into “one write plus an eventually-consistent projection you can replay.” Also build a reconciliation job that compares both sides and reports drift, and actually look at its output.
Events as a decoupling tool
Once more than one system exists, events let you extract behaviour without the monolith needing to know what happened next. When PHP publishes order.paid to Kafka, NATS, RabbitMQ or even an outbox table polled by Go, you can move notification sending, analytics, warehouse sync and invoice generation out one at a time, with no changes to the checkout path after the first one.
Two practical warnings. First, define the event payload as a contract with a version field from day one; events are an API, and an untyped JSON blob will haunt you. Second, assume delivery is at-least-once and make consumers idempotent, key handlers on an event ID or a natural business key, so a redelivery does not send a second invoice.
Shadow traffic: prove correctness before serving it
The best trick in this pattern is to run the new implementation against real production traffic while still serving the old response. The router mirrors the request to the Go service, the monolith’s response is what the client gets, and a comparison job records where the two differ.
request --> PHP monolith --> response to client
\
\--> Go service --> discarded
|
v
compare & log
Nginx’s mirror directive, Envoy’s request mirroring policy, or a small forwarding step in your existing gateway will all do this. What you learn is exactly what you cannot learn from tests: the input shapes real clients actually send, the legacy behaviours nobody documented, the rounding difference in the third decimal place, the timezone assumption in the old code.
Practical notes: exclude non-idempotent side effects from the mirrored path, or point the Go service at a sandbox for anything it would send outward, otherwise your shadow deployment will email customers twice. Compare responses structurally rather than byte-for-byte, and normalise fields that are legitimately allowed to differ, such as generated IDs and timestamps. Run it until the diff rate is boring, not until it is convenient.
Canary, then rollback as a first-class feature
When shadow comparison is clean, start serving the Go path to a small share of traffic, internal users, then a single percent, then a visible cohort. Watch error rate, latency percentiles and one business metric that would notice a subtle logic bug, such as conversion or average order value. Correctness bugs often show up in the business metric long before they show up in HTTP 500s.
Rollback must be a router change, not a deploy, and it should be one command that anyone on call can run without understanding the migration. If the only way back is to revert a release and run a down-migration, you have built a cutover and called it a canary. This is also the reason for the write-ownership rule above: a rollback is only safe if the old system never stopped being able to serve that path, which means you do not remove the PHP implementation on the same day you shift traffic.
Observability across two runtimes
For the duration of the migration you are operating one system with two implementations, and your tooling has to reflect that.
- Propagate trace context (W3C
traceparent) through the router into both PHP and Go, so a single request that crosses the boundary is one trace. - Emit the same metric names with a label for implementation, so you can compare latency and error rate on the same dashboard instead of two.
- Log the routing decision itself, including which upstream served the request and why. During an incident, the first question will be “was this the old path or the new one,” and you do not want to guess.
- Keep one alert per behaviour rather than per service, so the on-call engineer sees “pricing is failing” rather than two half-signals.
The mistakes that make a strangler migration fail
Strangling nothing. New services are added, but the PHP implementations are never removed and nothing is ever fully cut over. This is the most common failure, and it is worse than not migrating at all: two implementations, two deployment paths, two on-call surfaces, one team.
Boundaries drawn around tables. Covered above, and it deserves repeating because schema diagrams are seductive. Extract behaviours; move data as a consequence.
No removal step in the plan. Every slice should have a definition of done that includes deleting the old code path, the old feature flag, and the routing rule that made the split configurable. If deletion is not a ticket, it will not happen, because deleting working code is nobody’s favourite task and there is always a feature to build instead.
Indefinite dual maintenance. A bug is reported and someone has to decide whether to fix it in PHP, in Go, or in both. When that question is being asked six months in, the migration has stalled. Put a date on each slice, and if a slice has been half-migrated for two quarters, either finish it or roll it back and admit the boundary was wrong.
Distributing the monolith. If the Go service has to make three synchronous calls back into PHP to serve one request, you have not extracted anything; you have added network latency and a new failure mode to the same coupling. When you see this, the boundary is wrong. If your plan involves many small services from the start, the trade-offs in breaking a PHP monolith into services are worth reading before you commit, because the strangler pattern works just as well toward a small number of well-sized services as it does toward a large number of tiny ones.
A sequence that works
- Put the router in front of the monolith and change nothing else. Ship it. Confirm that traffic and latency are unchanged.
- Pick one narrow behaviour with a clear trigger and few writes.
- Build it in Go, reading data where the monolith owns it.
- Mirror production traffic to it and compare responses until the diff is uninteresting.
- Serve it to internal users, then a canary percentage, then all traffic.
- Move write ownership if the slice needs it, using an outbox or CDC rather than dual writes.
- Delete the PHP implementation and the routing rule.
- Repeat, and stop when the remaining PHP is either fine where it is or the last thing left.
Step 8 is not a joke. Plenty of systems reach a healthy end state where a stable PHP admin panel or reporting area simply stays, because moving it would cost real money and return nothing. Deciding that consciously is a good outcome. Deciding it by accident, after three years of dual maintenance, is not.
If you want a second opinion on where the first boundary should be for your specific codebase, and an honest answer about which parts are not worth extracting, that is the kind of question I work on in a PHP to Go migration engagement, starting with a fixed-scope readiness audit rather than an open-ended rewrite.