Index

Choose a Stack from Constraints, Not Identity

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. They can also make a terrible stack- selection rule if 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.

That qualification matters. Octane can remove repeated application boot and reuse warm workers, but 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 as close as this kind of 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. What it rules out is the usual apples-to-bananas rewrite story in which the new stack also receives a cleaner domain, a smaller feature set, and five years of hindsight.

The retained Postal 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 is not evidence of a completed production cutover. It is evidence that parity and measurement were treated as prerequisites instead of cleanup for later.

Migrate the boring slice first

The first Go slices were intentionally unglamorous:

  • Postal imported Posti data and answered a postal-code query.
  • Track accepted Posti tracking data and answered an event query.
  • Location handled Posti service points.

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 could be understood as more ports and adapters. That is a simplification, but it is a useful one: 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.

That sequence protects against a common mistake: making the incumbent prove itself against an imagined rewrite. The replacement should have 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. The opposite happened often enough to change how I estimated the work.

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.

This is not “the standard library is always better than dependencies.” A weak home-grown implementation does not become sound because it has no third-party imports. The difference was that these were language-level or well-supported standard primitives for routine service work. Removing packages also removed upgrade paths, framework integration, configuration, and interactions which had to be understood 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 Track, Location, and Postal, I do not have a current 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 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. Hold behaviour still. Make the replacement carry a real slice under representative traffic. Then choose the stack which changes the constraint enough to pay for leaving the one you already know.