Laayo Blog

Laayo's tech stack, part two: Go on the hot path

How we moved the busiest API calls from Laravel to Go one endpoint at a time, by running Go in the shadow first and comparing every answer before letting it serve.

Laayo's tech stack · Part 2 of 5
  1. 01The foundation
  2. 02Go on the hot path
  3. 03Realtime
  4. 04Going native
  5. 05Seeing what happens

In part one we said the hardest number to move in our Laravel backend is its fixed cost: around 170 ms per API call before any real work starts. For an admin page that is fine. For a customer opening the app, which makes dozens of calls in the first few seconds, it adds up to a home screen that feels slow even when every query is fast.

We wanted the speed of the best delivery apps in the world, with API responses under 150 ms at p95 and an app that feels open in under a second. Rewriting thousands of routes is not a plan. Instead we did what is sometimes called a strangler migration: put a Go service in front of the busiest paths, move them across one at a time, and leave Laravel in charge of everything else.

Rank by traffic, not by interest#

The first step was boring and important. We counted requests per route across nine days of logs and sorted them. The list was not what we would have guessed.

The busiest endpoint by far was rider location updates. Every online rider reports their position every few seconds for their whole shift. After that came small configuration and health calls that apps poll in the background, partner notification checks, and the lists of current orders that rider and partner apps refresh constantly.

None of these are glamorous, but each one paid the full Laravel start-up cost for a few milliseconds of work. Porting even trivial endpoints saved around 170 ms each, and because they were simple, they were safe first candidates.

The admin and partner web panels stay in Laravel for good. They are not on the customer’s critical path, and they hold the most business rules.

One endpoint, three modes#

Each Go endpoint is a small unit: how to match a request, how to answer it, and what differences are acceptable. On top of that sits a mode, set per endpoint in a config file that the Go service re-reads every ten seconds.

One endpoint, three modes: off sends the request to Laravel, shadow lets Laravel answer while Go computes the same response and compares, serve lets Go answer with Laravel as the fallback for anything unported or any error.
One endpoint, three modes: off sends the request to Laravel, shadow lets Laravel answer while Go computes the same response and compares, serve lets Go answer with Laravel as the fallback for anything unported or any error.
  • Off. Go ignores the endpoint. Laravel answers.
  • Shadow. Laravel still answers the customer. In the background, Go computes its own answer to the same request and compares status and body byte for byte. Mismatches are saved for us to inspect.
  • Serve. Go answers. If anything goes wrong, Go hands the request back to Laravel.

Shadow mode is the heart of the approach. It lets us test a port against real production traffic, with real data and real edge cases, while customers keep getting the proven answer. An endpoint only moves to serve when shadow shows it matching. For example, two rider payout endpoints matched on every shadowed request and answered in 2 to 4 ms in Go, against about 172 ms in Laravel. They went to serve the same morning.

Rolling back is a one-word change in the config file. No deploy, no restart. The service picks it up within ten seconds.

Matching PHP byte for byte#

“Byte for byte” is harder than it sounds when two languages encode JSON differently. A few things we had to get right:

  • Key order. PHP keeps object keys in insertion order. Go maps sort them. We built a small ordered-object type so Go emits keys in exactly the order Laravel does.
  • Escaping. Go escapes &, < and > in JSON by default. PHP does not. We turn that off.
  • Numbers. PHP prints floats in its own way. Go has a helper that prints them the same way, so a price or a coordinate looks identical.
  • Dates. Laravel formats some dates in UTC with a fixed pattern and others through model accessors. Each has a matching helper in Go.

Where an exact match is impossible, the endpoint can declare a tolerance. Distances calculated with slightly different maths libraries differ in the twelfth decimal place, and an age-in-seconds field changes between the two calls. Those are accepted explicitly, field by field, rather than by loosening the comparison for everything.

Fall back, never fail#

The Go service only handles the happy path it has been taught. Anything else, like a missing or invalid token or a validation error, is handed back to Laravel so the error the app sees is exactly the one it always saw.

There is also a safety net below the Go service. nginx sends native paths to Go, and if Go is unreachable or returns a gateway error, nginx retries the request against Laravel. We tested this by stopping the Go service on our tests environment in the middle of traffic. Apps kept working, just at Laravel speed.

Go also connects to the database with a read-only account, apart from a couple of narrowly scoped writes like rider positions. A bug in a Go handler can show wrong data, which shadow mode is designed to catch, but it cannot change orders or money.

Computing versus copying#

Some endpoints are easy to port because their logic is small. Others, like the store page and menu, carry years of rules about discounts, add-ons, schedules, translations and stock. Porting those in one step would be exactly the risky rewrite we wanted to avoid.

For those we use saved copies: Laravel renders the response as usual, saves it in the cache Redis, and Go serves the saved copy to the next request with the same key.

Saved copies that stay correct: Laravel renders and saves a copy keyed by the request and scope versions, Go serves it on the next request, and a watcher fingerprints the database and drops copies when anything they depend on changes.
Saved copies that stay correct: Laravel renders and saves a copy keyed by the request and scope versions, Go serves it on the next request, and a watcher fingerprints the database and drops copies when anything they depend on changes.

The key includes everything that changes the answer: the path and query, zone, module, language and a rounded location. It also includes scope versions: small counters for config, catalog and offers. When something changes in a scope, its counter goes up and every copy built on the old version simply stops matching.

For store lists on the home screen, Go goes one step further. It takes Laravel’s saved list and applies the parts that depend on you: which stores deliver to your exact pin, using the same polygon check as Laravel, and the distance sort. We verified 42 request variants on tests and production. Every one matched, apart from float noise in distances. Public response times for those lists dropped from 145 to 215 ms down to 35 to 58 ms.

We also added a combined home bundle. Instead of the app making seven separate calls for popular, latest, recommended and the other home sections, it makes one, and Go fills each part through the same handlers. Each part is identical to the single endpoint. The bundle takes 0.20 to 0.26 s, against 1.17 s for the same calls one after another.

Copies only help if they live long enough to be reused. Our first menu copies lived two minutes, and outside peak hours most stores do not get two menu views in two minutes. Measured hit rates were poor.

Making copies live longer is easy. Making them live longer and stay correct is the real problem. A price change, a sold-out item or a discount that starts at 6 pm has to show up right away.

So we wrote menuwatch, a small Go service that watches the database instead of waiting to be told about changes. Every few seconds it takes a fingerprint of the rows each store’s menu depends on: items, add-ons, discounts, schedules, store settings, categories, translations, flash sales and more. If a store’s fingerprint changes, its copies are dropped. If something shared changes, like a category, a counter goes up and every menu copy goes stale at once.

Time is a change too. A discount that starts at 18:00 does not edit any row at 18:00, so menuwatch also knows the clock boundaries in the data, such as discount windows, flash sale times, temporary closes and midnight, and treats each one as a change when it arrives.

With that in place, menu copies now live for six hours, and a repeat menu is served by Go in under a millisecond. The same watcher now covers offers and coupons, and when it sees a change it tells storelive, which pushes the update to apps. That part of the story is in part three.

Measuring who answered#

Every response from the Go service is counted with its route, status code and a served_by label: Go, a Go copy, or Laravel. That gives us an honest picture rather than a hopeful one.

It also told us where copies were not paying off. On some endpoints the copies missed most of the time, not because they were wrong but because traffic was too sparse for a short-lived copy to be reused. That is why we built menuwatch, and why our rule now is to prefer Go computing the answer from the database for heavy pages, and use copies mainly for endpoints that apps poll.

What’s next#

The next ports on our list are the cart with live pricing and the remaining store page parts, both computed in Go rather than copied. Both will go through the same off, shadow and serve path.

In part three we cover realtime: how order status, store open and close, chat and rider positions reach your phone in a fraction of a second, and why we replaced our WebSocket server with one Go process.

This is part two of our series on Laayo’s tech stack.

GoPerformanceLaravelCaching

Laayo Engineering

Backend, apps and infrastructure

The team that keeps orders, riders, payments and the apps running across Birtamod and Damak.