PHP to Go Migration: A Practical Decision Guide
A workload-first approach to moving parts of a PHP system to Go, including what Go fixes, what it does not, and how to roll out changes without betting the product on a rewrite.
Almost nobody wakes up and decides to rewrite a healthy PHP system in Go. The conversation starts because something hurts. Queue workers eat memory and get killed by the orchestrator. Traffic grew and the fleet grew faster than revenue. Deployments became scary. Nightly jobs no longer finish before the business day starts. Or ten years of coupling turned a once-pleasant codebase into something where every change requires a meeting. Go can be part of the answer to several of those problems. But “rewrite the PHP application in Go” is almost always the wrong first requirement. The better question, and the one a PHP to Go migration should actually start from, is this: which parts of the system are genuinely constrained by PHP, and which problems would follow you into any language?
That distinction is the whole job. Get it right and you extract two or three services, cut infrastructure cost, make a class of latency problems disappear, and keep shipping product the entire time. Get it wrong and you spend eighteen months reproducing business rules nobody remembers writing, in a language your team is still learning, while the PHP system you were supposed to be replacing keeps changing underneath you.
Why teams start considering a move from PHP to Go
The triggers are remarkably consistent across companies.
Memory per unit of concurrency. PHP-FPM handles one request per process. Each worker holds an interpreter, the framework bootstrap, and whatever the request allocates. Suppose, illustratively, a worker sits at 120 MB resident under a fat framework, 200 concurrent requests then implies roughly 24 GB of process memory before you have cached anything useful. Go handles concurrency with goroutines inside a single process: a goroutine starts with a small stack that grows on demand, and thousands of them share one heap and one runtime.
Long-lived connections. WebSockets, server-sent events, gRPC streams, and long-polling do not fit a process-per-request model. You can bolt on Swoole, RoadRunner, or FrankenPHP and get genuinely good results, but you are then running PHP in a long-lived mode that much of the ecosystem was not written for.
I/O fan-out. A request that needs to call six upstream services is naturally sequential in classic PHP. In Go it is six goroutines and a sync.WaitGroup, bounded by a context deadline.
Queue throughput and cost. Workers that spend most of their time waiting on network I/O are exactly where a process-per-job model is most wasteful.
Deployment shape. A Go service compiles to a static binary. No runtime, no extension matrix, no composer install on the box, no opcache warmup. That is an operational simplification, and operations teams notice it.
None of those are opinions about which language is nicer. They are properties of two different execution models. If you want the full side-by-side, I wrote a longer comparison in PHP vs Go for backend systems.
What Go actually improves
Be precise about the wins, because vague expectations are how migrations lose their budget.
- Concurrency per process. One Go process can hold tens of thousands of open connections with modest memory. This is the single biggest structural difference.
- Tail latency under load. With no per-request bootstrap and a compiled runtime, p99 tends to be flatter as concurrency rises, assuming your database is not the bottleneck.
- CPU-bound work. Parsing, encoding, cryptography, image and file processing, and large in-memory transformations are typically an order of magnitude cheaper than in interpreted PHP.
- Connection pooling that behaves.
database/sqlgives one shared, bounded pool per process. In PHP-FPM, each process holds its own connection, so your database connection count scales with your worker count, which is why PHP fleets so often need PgBouncer. - Deployment and supply chain. A single binary, reproducible builds, a small container image, no runtime extension drift.
- Predictable resource limits. Setting
GOMAXPROCSand a memory limit for one process is easier to reason about than tuningpm.max_childrenagainst a memory ceiling.
What Go does not automatically solve
This is the part vendors skip.
Go will not fix a slow query. If a report takes nine seconds because it does a sequential scan over 40 million rows with no supporting index, rewriting the caller in Go changes the nine seconds into eight point nine seconds. Read the query plan first.
Go will not fix N+1 access patterns, chatty service boundaries, missing caching, or a schema that requires four joins to answer a simple question. It will not fix a domain model where “order” means three different things depending on the module. It will not fix a release process where nobody trusts the test suite.
Go also has costs that show up later: less framework structure, so teams must agree on their own conventions; a smaller pool of engineers in some European markets than PHP; verbose error handling that is excellent for reliability and tedious for CRUD; and an ORM story that is deliberately thinner than Doctrine or Eloquent, which is a feature for services and a burden for admin panels.
Before committing to a migration, spend a week proving the constraint is the language. Profile it. If you have never done that systematically, profiling PHP before deciding on a rewrite walks through the process: enabling a sampling profiler in production, reading flame graphs, checking opcache hit ratio and JIT settings, looking at query plans, and separating time spent in PHP from time spent waiting.
When PHP should stay
I say this to clients regularly, and it is why they trust the rest of the advice: a large amount of PHP should never be migrated.
Keep PHP for admin interfaces and internal back-office tools. Keep it for CRUD-heavy modules where the framework’s scaffolding, validation, and form handling are doing real work. Keep it for anything rendering server-side HTML with a mature template layer. Keep it for the parts of the domain that change weekly for business reasons, that is where development speed matters more than runtime efficiency, and modern PHP 8.x with a good framework is very fast to change.
Keep it, too, when the code is simply working. A stable, well-tested billing module that has run for six years is an asset. Rewriting it converts a known quantity into an unknown one, and the new version will not be better on day one; it will be less battle-tested.
A useful rule: migrate where the execution model is the problem, not where the code is old.
Good first workloads for Go
The best candidates share three properties: a clear input/output contract, few dependencies on framework internals, and a measurable pain today.
| Workload | Why it fits Go | Typical risk |
|---|---|---|
| Queue consumers | Long-lived process, high I/O wait, memory-bound today | Duplicate delivery during cutover |
| Webhook ingestion | High concurrency, trivial logic, must never drop | Signature verification parity |
| WebSocket / SSE gateway | Persistent connections, thousands per process | Auth token validation across the boundary |
| File and media processing | CPU-bound, parallelizable | Storage permissions and paths |
| Read-heavy public API | Flat tail latency, tight caching control | Response shape drift |
| Scheduled batch exports | Streaming instead of loading into memory | Time zone and cursor semantics |
Notice what is not on the list: the checkout flow, the admin panel, and the pricing engine. Those carry the most business rules and the least language-related pain.
Queue workers are usually the correct first move, and they deserve their own treatment, see moving PHP queue workers to Go for the details on idempotency keys, at-least-once delivery, and running both consumers side by side.
Which candidate is cheapest to extract also depends on the framework you are extracting it from. Horizon and Eloquent shape the answer differently than Doctrine and Messenger do, so it is worth reading the framework-specific version of this decision: what to move and what to keep in a Laravel codebase, or the same question for a long-lived Symfony product.
Why a full rewrite is dangerous
The big-bang rewrite fails in a predictable way, and it is worth naming the mechanism rather than just warning against it.
You freeze or slow feature work on the PHP system so the team can build the Go system. The business does not stop, so exceptions accumulate and the PHP system keeps moving. Your Go target is now chasing a moving specification written in code nobody has fully read. Meanwhile the only complete specification of the current behaviour is the current behaviour, including the bugs that downstream consumers now depend on.
Then the cutover arrives. It is a single event with no partial success state. If it goes wrong at 02:00, the rollback is a database restore, not a traffic switch. And because nothing shipped for a year, nobody can point to incremental value to justify the next quarter of budget.
Incremental migration inverts every one of those properties: value ships continuously, each step is individually reversible, and you learn about the domain in small, cheap increments.
Strangler-style migration in practice
The pattern is straightforward. Put a routing layer in front of the system. Move one capability at a time behind that layer. The PHP application keeps serving everything else. Over time, more traffic terminates in Go, and the PHP surface shrinks, or stops shrinking, deliberately, at the point where further migration stops paying.
┌──────────────┐
client →│ edge / router │
└──────┬───────┘
┌───────┴────────┐
▼ ▼
┌─────────┐ ┌───────────┐
│ PHP app │ │ Go service │
└────┬────┘ └─────┬─────┘
│ │
└──── shared ────┘
database
Two decisions dominate the outcome.
The routing layer. Nginx, Traefik, an API gateway, or an envoy sidecar all work. What matters is that switching a route from PHP to Go is a config change you can revert in under a minute, and that you can send a percentage of traffic rather than all of it.
The data boundary. Sharing a database between PHP and Go is the pragmatic starting point and it is fine, provided you decide who owns writes for each table. Two codebases writing the same rows with different assumptions about defaults, timestamps, enum handling, and soft deletes is how you get corruption that surfaces three weeks later. Start with the Go service reading, then let it own writes for tables PHP no longer touches.
A deeper treatment of the sequencing, including how to handle shared sessions and dual-write windows, is in the strangler pattern applied to PHP and Go.
If you would rather have someone independent map which workloads are actually worth extracting before you commit engineering quarters to it, that assessment is exactly what my PHP to Go migration consulting work covers, including the outcome where the honest recommendation is to keep the workload in PHP and fix the query plan instead.
Rolling out to production without drama
Treat every extraction as a production change, not a project milestone.
Run the Go service in shadow mode first: it receives a copy of real traffic, does the work, and its output is compared against PHP’s rather than returned to users. For queue consumers, the equivalent is consuming from a mirrored queue and writing results to a staging table. Diff the outputs. You will find behaviour differences you did not know existed, usually around null handling, rounding, date formats, and character encoding.
Then canary. One percent of traffic, then five, then twenty-five, then half. Hold at each step long enough to cross a full traffic cycle, which for most B2B products means at least one business day. Watch error rate, p50 and p99 latency, and the business metric the endpoint affects, not just the technical ones.
Keep rollback to a single config change for the entire canary period, and keep the PHP path deployable and tested the whole time. Do not delete it the week the canary reaches one hundred percent. Give it a month.
For consumers, design for at-least-once delivery from the start. Every message handler needs an idempotency key and a store of processed keys, because you will replay messages during cutover and during incidents. Apply backpressure deliberately: bound the number of concurrent handlers with a semaphore or a worker pool, and make sure a fast consumer cannot overwhelm a slower database.
Benchmarking honestly
Most migration benchmarks are useless because they compare a tuned Go implementation against an untuned PHP one, or they measure a synthetic endpoint that does no I/O.
Benchmark the real workload with the real database and realistic data volume. Include the network. Measure at a fixed concurrency and report a latency distribution, not an average. Run both versions against the same dataset on the same hardware. And measure the thing that costs money: for an API, requests per second per core and memory at your target concurrency; for consumers, messages per second and time to drain a backlog of a known size.
Also measure the PHP side after a genuine tuning pass, opcache sized correctly, JIT considered, preloading where applicable, the N+1 queries removed. If tuned PHP gets you to the target, that is a result worth having. It costs a fraction of a migration, and it is the honest answer often enough that I quote modernization work separately from extraction work.
A migration checklist
Work through this in order. If you cannot answer a question, that is the next task.
- Write down the specific pain in measurable terms: p99 latency, memory per worker, backlog drain time, or monthly infrastructure spend.
- Profile the PHP application in production with a sampling profiler; identify how much time is PHP execution versus waiting on I/O.
- Pull query plans for the ten slowest queries; fix indexes and N+1 patterns before deciding anything.
- Check opcache hit ratio, memory limit, and whether preloading or JIT applies to your workload.
- List every workload in the system and classify it: latency-sensitive, throughput-sensitive, CPU-bound, or change-sensitive.
- Mark the workloads that stay in PHP permanently, and say why in writing.
- Pick exactly one first candidate with a clear contract and a real, current pain.
- Decide table ownership for the shared database: which side writes what, and what stays read-only.
- Define the routing layer and prove you can shift traffic by percentage and revert in under a minute.
- Agree on the observability contract: same trace IDs, same log format, same metric names across PHP and Go.
- Implement idempotency keys and a processed-message store for any consumer you move.
- Run the Go implementation in shadow mode and diff outputs against PHP for at least a week.
- Canary at 1 / 5 / 25 / 50 / 100 percent, holding at each step across a full business cycle.
- Keep the PHP path deployable for at least a month after full cutover.
- Re-measure the original metric and decide whether the next extraction is still worth it.
That last item is the one teams skip. A migration is not a plan to be completed; it is a sequence of independent decisions, each of which should be justified by the state of the system at the time. It is entirely reasonable for the answer after two extractions to be “the rest stays in PHP”, that is not a failed migration, it is a finished one.