By the time I considered moving the pricing backend to Go, the PHP version already had table-driven pricing, prepared runtime projections, candidate indexes, and substantial cache work. Those changes mattered. They didn’t remove the request cost I was still seeing before business processing: roughly 50–100 milliseconds on the conventional PHP/Laravel path, reduced to roughly 30–40 milliseconds with Octane in the setup I tested.
I didn’t want to spend the rest of the project squeezing a little more out of that same path. Go was a better fit for the request and computation work, particularly as the pricing datasets grew. Performance, resource consumption, and stability drove the choice. The interesting engineering question became how to replace the runtime without quietly replacing the application too.
An empty Go handler makes an attractive comparison. My backend needed rather more than an empty handler.
Octane had already removed repeated boot work
The conventional PHP entry point was familiar:
require __DIR__.'/../vendor/autoload.php';
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());That’s a shortened excerpt of the application’s public entry point. The bootstrap registered routes, middleware, commands, and exception rendering. It wasn’t a fair description of what Octane repeated for every request, though. Octane boots the application once and keeps it in memory . The inspected worker then cloned that base application into a request sandbox and passed it through its application gateway.
That distinction matters. Blaming every remaining millisecond on a complete framework boot would have been a convenient explanation, but the persistent worker had already changed that lifecycle. Routing, admission, authentication, input transformation, dispatch, and response handling still had to happen. Some belonged to the application contract; some came from how the framework implemented that contract. “Octane is enabled” didn’t tell me their combined cost was now acceptable.
The quote controller was already deliberately thin, and the hot path used a dedicated validator rather than expanding a FormRequest. Its successful application handoff looked like this:
$validated = $this->requestValidator->validate($request);
$result = ($this->evaluateShippingOptions)(
$this->requestMapper->mapValidated(
$request,
$validated,
$application,
),
);Failure rendering and document construction followed that handoff. Moving those lines into a different controller class wouldn’t remove the surrounding request path. Neither would another tariff-row micro-optimization make the application cheaper to enter. I had to distinguish work which could be made smaller from work whose implementation I wanted to replace.
The initial port discussion recorded a minimal-response comparison of about 5 milliseconds p99 in Go and 40 milliseconds p99 with Laravel Octane. Those figures and the broader setup ranges above are observations from that testing, not a universal cost of either language. They don’t establish an eightfold improvement for a completed quote. The full-request workload and statistics need to match before making that claim.
The timers didn’t divide the request into independent boxes
The benchmark chapter explains why a successful replay couldn’t stand in for a new calculation. There was a second interpretation problem: the stage timers didn’t all measure disjoint pieces of the request.
For example, the PHP authentication middleware’s route timer began before resolving the bearer user and ended after the downstream handler returned. Its authentication timer covered part of that interval, and its database measurement covered queries inside authentication. In condensed form:
authenticated-route interval
bearer interval
bearer database queries
downstream middleware and quote handlerThe route duration therefore contained the authentication duration. The database time wasn’t another independent amount to add alongside both. Likewise, runtime context resolution could contain ruleset resolution, which could contain cached-payload restoration. A trace could be useful without being a spreadsheet of additive costs.
There was also work before that authenticated-route timer began. Subtracting its duration from the client’s latency wouldn’t isolate “PHP overhead”: the remainder could include earlier middleware, transport, response work outside the interval, and differences between the client and server measurement boundaries. An empty-route control and internal stage timings answer different questions.
I kept three comparisons conceptually separate: the minimal request path, the evaluator’s computation, and a complete new quote with its normal admission and durable result. The PHP optimizations in the earlier chapters changed the latter two. They shouldn’t be credited to a later language change, and the bare-response comparison shouldn’t borrow the full quote’s name.
Go also performed better at rule evaluation and computation in my testing, especially on larger datasets. That was part of the motivation, but the minimal-response numbers don’t quantify it. The historical full-quote report and the request-baseline observations aren’t a matched PHP/Go experiment from which to calculate a single clean multiplier. I’d rather keep those boundaries visible than produce an impressive ratio which combines different tests.
The Go server still had an application in front of the calculator
The new server used Go’s net/http underneath a router and application
adapters. It constructed its dependency graph and route registrations before
serving requests. Authentication, repositories, validation, queues, and the
evaluator were wired into that graph; they weren’t all recreated by an
empty handler on every call.
The serving composition wrapped the compiled router. Expanded from the source’s nested expression, with application package aliases shortened, it looked like this:
handler = withHTTPApplicationRoutingCatalogMemo(handler)
handler = sharedhttp.WithNormalizedRequestInput(handler)
handler = sharedhttp.WithAPIExceptionHandling(
handler,
ratinghttp.ResolveRuntimeAdmissionException,
)
handler = sharedhttp.WithResponseHeaders(handler)
handler = sharedhttp.WithRequestMethod(handler)
handler = boundHTTPApplicationConcurrency(handler, limit)
server := &http.Server{
Addr: address,
Handler: handler,
}Each assignment wraps the previous handler, so requests encounter the last wrapper first. Concurrency admission sits outside method resolution and the response policy; request-local routing memoization sits nearer the compiled router. That order is behavior, not just wiring style.
The quote route then added its own protection: JSON:API headers, pinned API version, bearer authentication, rate limiting, token-type and permission checks, plus its runtime-operation context and request-size guard. The handler read and decoded the body, validated it, mapped trusted authorization into application input, invoked the command, and normalized the result into a JSON:API response. Error branches could stop that sequence before the calculation. Removing them would make the route faster by changing what clients were allowed to do and what they received when something went wrong.
This is where “Go has almost no framework overhead” can become a misleading
description. The language didn’t make those responsibilities disappear.
It let me choose a different implementation and runtime for them. Whether
that complete implementation met the latency and resource goals remained a
measurement question, not a property inherited from http.Handler.
Persistent workers made admission and request state explicit
The Go composition included a small concurrency gate before handler execution. This is the source helper, with its signature wrapped for readability:
func boundHTTPApplicationConcurrency(
handler http.Handler,
maxConcurrentRequests int,
) http.Handler {
requests := make(chan struct{}, maxConcurrentRequests)
return http.HandlerFunc(func(
writer http.ResponseWriter,
request *http.Request,
) {
select {
case requests <- struct{}{}:
defer func() { <-requests }()
case <-request.Context().Done():
return
}
handler.ServeHTTP(writer, request)
})
}With a positive limit, an occupied slot stays occupied until the wrapped handler returns, including unwinding through a panic. If the gate is full, a cancelled request can stop waiting without entering the handler. This bounds execution beyond the gate; it doesn’t bound accepted connections or the number of goroutines waiting for a slot. Calling it a complete overload policy would claim much more than these lines implement.
The distinction matters to both measurement and operations. A quote waiting outside the evaluator still has latency. More goroutines don’t make a database pool, CPU budget, or shared downstream resource unlimited. Go made concurrency straightforward to express, not free of constraints.
Request-local routing memoization was installed through the request context, while the compiled router remained reusable. That was another lifetime decision, related to the persistent-worker concerns in the cache chapter . A process can reuse configuration without treating the previous request’s mutable state as configuration. The listener also had signal-driven shutdown with a bounded shutdown attempt; a single binary still needed lifecycle handling.
Some of the framework had become observable behavior
Laravel had been doing things which weren’t written in the controller’s four-line handoff. Input transformation is a small example. Passing a JSON document through its trimming and empty-string conversion stages gave these values:
{
"reference": "BOX-1",
"note": null,
"numeric_text": "0",
"number": 0,
"flag": false,
"nested": {"note": null}
}The supplied reference was " BOX-1 ", both notes contained only spaces,
and the numeric string was " 0 ". Trimming didn’t turn the number or boolean
into strings. Empty-string conversion didn’t turn zero into null. These
results come from executing the framework middleware, not from assuming that
all languages interpret empty values alike.
The Go transport had corresponding recursive normalization for strings, arrays, maps, and its ordered JSON-object representation. Its method-override handling also preserved PHP string truthiness and the source’s input-loading order. A naive decode-into-a-struct handler could be perfectly idiomatic Go and still behave differently before the rating engine saw the input.
This wasn’t a reason to preserve Laravel as a dependency in the new server. It was a reason to identify its externally visible behavior before removing it. The physical schema, API contracts, algorithmic choices, failure order, and persisted outcomes stayed inside the faithful-port boundary. Backend behavior didn’t become optional because the implementation had previously hidden it behind a framework helper.
The explicit exceptions concerned presentation: the website and administrative UI weren’t being ported, while the tester’s execution capability was to move to an internal CLI. That reduced the surface without shrinking the backend to “calculate a number.” The large PHP suite, fixtures, and tester scenarios were useful precisely because so much behavior wasn’t visible in a quick tour of the happy-path controller.
Why I moved the backend rather than just the calculation
Keeping PHP at the edge and calling a Go calculator was a credible alternative. If calculation dominated and the existing edge already met the request budget, that could preserve useful application machinery while replacing a smaller piece. It would also keep PHP’s entry cost and add an interface to version, serialize across, observe, and recover when calls failed. A faster calculator can’t remove the unchanged front of that request.
A leaner PHP HTTP application was another possibility. My measurements weren’t evidence that every PHP application pays the tested Laravel cost. Rebuilding the necessary edge behavior in a smaller PHP stack would have been its own engineering project, though. Familiar language doesn’t make that work free, just as Go’s standard library didn’t implement the whole backend for me.
For this engine, I chose the backend port. The product remained private, which removed a live customer cutover from this story; it didn’t remove the need for compatible data and behavior. Performance, lower resource consumption, and stability were reasons for that choice, not three outcomes proved by a minimal-response latency result. Memory and reliability improvements would need their own evidence.
I wanted to stop fighting the runtime, not use a rewrite as permission to quietly redesign everything. The next part of the work had to make the PHP implementation precise enough to preserve, including the behavior we might have preferred to change. Otherwise a faster quote could simply be a quote which no longer did the same job.