We did not replace Laravel because Go was fashionable. We replaced it because CPU had become the limiting resource under concurrent inbound traffic, and the Laravel services had reached the point where more framework tuning no longer changed that fact.
After the migration, CPU use fell by almost 60 percent. The average endpoint became sixteen times faster while the services handled forty times as much traffic. We moved from machines with 16 CPU cores and 32 GB of memory to four cores and 16 GB. Infrastructure cost halved. Incidents fell.
Those numbers make a good Go story and a terrible stack-selection rule when they are detached from the business pressure which made the migration worth funding.
The useful part is not that Go won. It is that we could name the constraint, change the stack without changing the service contract, and test the replacement under traffic representative of the system it would inherit. That turned a language preference into a capacity and cost decision.
CPU was the constraint, not an excuse
“PHP is slow” would not have been enough to approve a rewrite. Neither would a Hello World benchmark. A framework boot, database query, JSON encoder, network call, and badly chosen index can all appear in one request profile. Changing the language because the total is slow gives every cause somewhere to hide.
Our services and PostgreSQL queries had already been heavily optimized. They were running with Laravel Octane, so repeated framework boot was not the main remaining cost. At high concurrency, CPU was still the bottleneck.
Octane can remove repeated application boot and reuse warm workers. It cannot remove the work the PHP runtime and framework perform for each request. In a lower-volume application, that residual cost may be irrelevant. In ours, multiplying it across concurrent requests made it the capacity boundary.
The decision question was therefore narrow:
Can we preserve the existing behaviour while materially reducing CPU cost per request under representative concurrency?
That question does not contain a preferred language. It contains a measurement and a compatibility constraint.
Shadow traffic made the comparison worth discussing
We spent several weeks benchmarking with shadow traffic before approving Go. The business rules and externally visible behaviour stayed the same. We did not remove an old subsystem, simplify the response, or repair unrelated database debt and then credit the language.
That is about as close as a production comparison gets to changing one variable. It is still not a scientific law: runtime versions, compiler output, kernel scheduling, deployment settings, and implementation details remain in the result. It does rule out the usual apples-to-bananas rewrite story where the new stack also gets a cleaner domain, a smaller feature set, and five years of hindsight.
The retained postal-data proof of concept shows the comparison discipline. It implements one existing JSON-RPC method over the same PostgreSQL data, keeps a captured response shape down to nulls and empty strings, and exercises exact, prefix, locality, no-result, and multi-result queries at several concurrency levels. Its benchmark notes explicitly refuse to compare the local Go samples with Laravel until both sides share rows, request bodies, warmed databases, network topology, connection reuse, duration, and resource limits.
That POC does not prove the production cutover. It shows that parity and measurement were prerequisites, not cleanup we promised to do later.
Migrate the boring slice first
The first Go slices were intentionally unglamorous:
- A postal-data service imported one provider’s data and answered a postal-code query.
- A tracking service accepted the same provider’s tracking data and answered an event query.
- A service-point catalogue handled that provider’s locations.
Each slice crossed the whole system—input, application behaviour, persistence, and response—but kept the amount of behaviour small enough to compare. Once those paths worked, most of the remaining implementation was more ports and adapters. That simplifies the details, but not the decision: carrier-specific complexity did not require a new architecture for every carrier.
A horizontal migration would have been harder to judge. Replacing every HTTP controller while retaining PHP workers, or rewriting every database adapter before one request worked, would create a large investment without a production question it could answer. A vertical slice could fail cheaply.
It also left the Laravel implementation available as the control. Compatibility was not whatever the new code happened to return. It was the behaviour existing consumers already depended on.
Go and Rust were not competing for our identity
Rust was a genuine alternative. We already use it for command-line programs.
For these web and network services, I find Go easier to maintain because the
language is deliberately small and the relevant HTTP ecosystem is mature. The
standard net/http package
already provides the client, server,
handler, transport, timeout, and connection-reuse primitives we needed.
That is a team judgement, not a verdict on Rust. A team with deep Rust service experience, stricter memory-safety constraints, or a workload dominated by computation rather than ordinary network orchestration could choose differently. Our ability to operate the result mattered more than the maximum performance either language could reach in a specialist’s hands.
Staying on Laravel was also a genuine alternative. It had the lowest migration cost and the most team familiarity. We rejected it only after profiling, optimization, Octane, and representative traffic showed that it no longer met the capacity constraint economically.
The incumbent should not have to prove itself against an imagined rewrite. The replacement has to demonstrate the advantage. Familiar software gets credit for the migrations, operational knowledge, and failure modes already paid for.
Fewer dependencies changed the maintenance calculation
I expected some parts of the rewrite to be harder. Quite a few turned out to be simpler because Go already had the primitive instead of sending us to another package.
Go supplied primitives in the language or standard library for work that had required several PHP packages. Structs gave request, response, and persistence data a direct typed shape. We did not need a separate DTO package to give ordinary data a typed shape. The HTTP server, graceful shutdown, concurrency, and much of the I/O machinery were similarly close at hand.
That does not mean the standard library is always better than dependencies. A weak home-grown implementation does not become sound because it has no third-party imports. These happened to be language-level or well-supported standard primitives for routine service work. Removing packages also removed upgrade paths, framework integration, configuration, and interactions we would otherwise have to understand during maintenance.
Dependency count was not the original bottleneck. It became a material secondary benefit after the performance case had earned the migration.
Keep different infrastructure decisions separate
Choosing Go did not force us to replace PostgreSQL, Redis, or Valkey. A stack decision becomes hard to inspect when a language migration quietly turns into a database, broker, deployment, and architecture migration too.
Redis or Valkey remains the simpler multipurpose tool for work whose loss and
recovery contract permits it. Their configuration can evict keys when memory
reaches maxmemory; the Redis eviction documentation
also
provides a noeviction mode which rejects new writes instead. Neither behaviour
is a durable-delivery contract by itself.
For a service where losing an event is unacceptable, RabbitMQ is a credible later decision. Its reliability guide makes the extra contract visible: publishers need confirms, consumers need acknowledgements, and at-least-once delivery means consumers must handle duplicates. “Use a real queue” is not enough. The application must pay for the semantics it needs.
That choice remains open because it answers a different constraint. The Go migration answered CPU, latency, capacity, and maintenance. Event delivery must be decided from loss tolerance, replay, ordering, idempotency, and operational cost.
The evidence does not point back to Laravel here
For these three services, I do not have a credible reversal condition which would justify returning to Laravel. The observed difference is too large: almost 60 percent less CPU, sixteen times faster average endpoints, forty times the traffic, half the infrastructure cost, and simpler maintenance on smaller machines.
That does not turn Go into the default for every new service. The reversal question applies to the decision, not just the selected tool. I would choose Laravel where delivery speed, existing PHP capability, framework features, and moderate load make runtime overhead economically irrelevant. I would revisit Go if the team could not operate it, its ecosystem could not meet a required integration, or migration parity became more expensive than the capacity it released.
The result also has an evidence limit. The production figures above are my operational observations. The retained local postal-data benchmark is a short POC baseline, not independent proof of those production outcomes. It records an in-memory handler at 26 microseconds per operation, an uncached PostgreSQL search at 0.573 milliseconds, and the exact environment and caveats around them. I am keeping those two categories separate because a good decision does not need a benchmark to claim more than it measured.
Start with the bottleneck and hold behaviour still. Make the replacement carry a real slice under representative traffic. If it changes the constraint enough to pay for leaving the stack you already know, you have a business case. If it does not, you have a benchmark and can stop pretending it is a migration plan.