Moving Laayo from Laravel to Go, one route at a time.
A strangler-fig migration of a Laravel monolith to Go: per-route switches, shadow comparison on live traffic, write diffs, a release gate and instant rollback.
Laayo's backend started as one large Laravel application. It serves the customer, restaurant and rider apps, the operations staff, the website and every scheduled job. I am moving it to Go while it runs. No feature freeze, no maintenance window, no app release needed for the switch. This is how the migration works, what broke along the way, and what is still on PHP.
The problem and the constraints
The reason to move was speed and cost, and both come from the same place. Every Laravel request boots the framework before it does any work, so even a trivial endpoint pays a fixed price on every call. Laayo runs in Birtamod and Damak in eastern Nepal. Many customers are on cheap Android phones and patchy mobile data, and the home screen alone makes several calls on launch. A slow server is something they actually notice. The servers are small, so CPU spent booting PHP is CPU not spent on anything else.
The constraints shaped every decision:
- One developer. I wrote nearly all of Laayo myself and also run the business. There is no team for a big-bang cutover, and I cannot stop shipping for months.
- Old app builds in the wild. People do not update apps promptly. Whatever Go returns has to be byte-for-byte what the old app versions already parse, including every PHP quirk in the JSON.
- Real money and real deliveries. Restaurants, riders and customers depend on the system at every hour the service is open. Downtime and silent data corruption are both unacceptable.
- A big surface, most of it quiet. The monolith has a very long tail of routes that almost nobody calls. Porting by traffic, not alphabetically, is the only order that makes sense.
So the plan was the strangler-fig pattern: put Go in front, move routes over one at a time, prove each one on real traffic before it answers anyone, and keep Laravel as a live fallback until the last route is gone.
What I built
There are five pieces.
nginx as the first switch. A location block matches the paths Go knows about and sends them to the Go API. If the Go process is down or answers with a gateway error, nginx hands the request to Laravel instead. Every other path goes straight to Laravel. Turning Go off entirely is one config change.
A route table inside Go. Each ported endpoint is a small struct: a name, a method, a path matcher and a handler. A plain text file maps endpoint names to modes, and Go re-reads it every few seconds, so I can flip a single route without a restart or a deploy. An endpoint that is not listed is off. Go proxies everything it does not serve to Laravel itself, so nginx only needs to know roughly which paths might be Go's.
Shadow comparison. In shadow mode Laravel answers the client. Go keeps a copy of Laravel's response, computes its own answer for the same request in the background, and compares the two.
A write-diff harness for routes that change data, where comparing responses is not enough.
A release gate: tests, then staging, then production, with a security review inside every step and a watchdog that can undo a production change on its own.
How it works in detail
Modes. A route has one of five modes:
- off: Go ignores the path and Laravel answers.
- shadow: Laravel answers, Go computes in the background and compares.
- serve: Go answers, and Laravel is the fallback when Go fails.
- auto: shadow until the route has collected enough clean matches since the process started, then serve. Any mismatch or Go error sends it back to shadow until the next restart.
- behind: compare only. It exists for paths where Go already answers through a cache, so the new endpoint can be proven without taking traffic back from Go.
Lookup walks the endpoint list in order and returns the first match that is switched on:
type Mode int
const (
Off Mode = iota
Shadow
Serve
Auto
Behind
)
// find returns the endpoint that should handle r, or nil for "let Laravel answer".
// An endpoint that is off must not hide a later endpoint for the same path.
func (rt *Routes) find(r *http.Request) (*Endpoint, Mode) {
for _, e := range rt.list {
if e.Method != r.Method || !e.Match(r.URL.Path) {
continue
}
m := rt.mode(e.Name)
if m == Off || m == Behind {
continue // fall through to the next candidate
}
if m == Auto {
if rt.cleanMatches(e.Name) >= rt.autoThreshold {
return e, Serve
}
return e, Shadow
}
return e, m
}
return nil, Off
}type Mode int
const (
Off Mode = iota
Shadow
Serve
Auto
Behind
)
// find returns the endpoint that should handle r, or nil for "let Laravel answer".
// An endpoint that is off must not hide a later endpoint for the same path.
func (rt *Routes) find(r *http.Request) (*Endpoint, Mode) {
for _, e := range rt.list {
if e.Method != r.Method || !e.Match(r.URL.Path) {
continue
}
m := rt.mode(e.Name)
if m == Off || m == Behind {
continue // fall through to the next candidate
}
if m == Auto {
if rt.cleanMatches(e.Name) >= rt.autoThreshold {
return e, Serve
}
return e, Shadow
}
return e, m
}
return nil, Off
}That continue on an off endpoint is a fix, not the original design. More on that below.
Shadow, and what "identical" means. In shadow mode a response writer wraps Laravel's response, passes it through to the client and keeps a copy of the body, decompressing it if needed. After the client has its answer, Go runs the handler with a dry flag that forbids any write or side effect, then compares the status and body byte for byte.
Go maps marshal with sorted keys, PHP arrays keep insertion order, so I use an ordered-object type for every response. PHP turns floats into strings with its own precision rules, escapes slashes, turns an empty array into [] even where the app expects an object, and formats Eloquent date casts in its own way. One mismatch traced back to how PHP and Go disagree about Nepal's historical time zone offset for very old dates. Each quirk lives once, in a shared encoder.
Some differences are legitimate. A countdown in seconds can tick between Laravel's answer and Go's, so an endpoint can declare a few integer fields that may differ by a small amount. Lists whose order is not fully determined can supply their own comparison. A handler can also return a special "fall back" error for any input it does not cover, such as a bad token or a validation failure, so error bodies stay exactly Laravel's and are not counted as mismatches.
Shadow work runs in a small bounded pool. If the pool is full, the comparison is skipped and counted, never queued, so shadow load can never slow down real requests. Every route keeps match, mismatch, error, skipped, served and fallback counters on a health endpoint, saved to disk so the evidence builds up across restarts. A mismatch writes a sample file with both bodies into a private state directory with owner-only permissions, because the bodies can contain personal data.
Serving, and never replaying a write. In serve mode Go answers, and any error falls back to Laravel. For reads that is always safe. For writes it is dangerous: if Go's transaction committed and then something failed, sending the same request to Laravel would perform the write twice.
So every write path records how far it got: nothing begun, begun and rolled back, or commit reached. A request may be replayed to Laravel only when no write began, or when it was rolled back and the handler explicitly said "not mine". If a commit may have happened, Go does not replay. It returns a plain server error that tells the user to check before trying again. It deliberately does not return a gateway error, because nginx would turn a gateway error into a Laravel replay on its own.
Proving writes. For endpoints that change data I wrote a write-diff tool. A scenario file names a test account, the steps to run, the tables to watch (with a row scope), the Redis keys to watch, and the realtime channels to listen on. The tool snapshots the watched state, runs the steps against Laravel, records every row, key and event that changed, restores the snapshot and checks the restore, then runs the same steps against Go and compares. Sensitive columns appear in reports only as short hashes, so a report can be shared without leaking anything. It runs only against the test database.
Database users as a guard rail. The Go API reads through a database user that can only SELECT. Writes go through a separate user whose grants are column-level and generated from the code: a handler can update exactly the columns it is known to write, and it cannot read secret columns at all. A bug in a port cannot quietly write somewhere it should not, because the database refuses.
Caches that copy Laravel. Before most routes had real Go implementations, I tried a shortcut for read-heavy endpoints. Laravel renders the answer once and saves it with a fingerprint of the rows it depends on. Go serves the saved copy for as long as the fingerprint is unchanged. The fingerprint is cheap enough to compute on every request:
-- Changes whenever any row in scope is inserted, deleted or edited,
-- including writes that bypass the ORM's events.
SELECT COUNT(*) AS n,
BIT_XOR(CRC32(CONCAT_WS('|', id, updated_at, status, price))) AS h
FROM items
WHERE store_id = ?;-- Changes whenever any row in scope is inserted, deleted or edited,
-- including writes that bypass the ORM's events.
SELECT COUNT(*) AS n,
BIT_XOR(CRC32(CONCAT_WS('|', id, updated_at, status, price))) AS h
FROM items
WHERE store_id = ?;Scheduled jobs. Cron jobs move the same way. Each job has an owner switch, Laravel or Go. Laravel's scheduler checks it and skips jobs that Go owns. Before a flip, a proof run shows that each schedule slot executed exactly once, never twice and never zero times.
The gate. Every wave goes through the same steps. I build a test binary from a clean staging tree. The new routes run in shadow on the test environment, and writes go through the write-diff tool. Then comes a separate security review covering access control and object-level authorization, injection, rate limits, secrets, uploads and payment callback signatures. After that it goes to staging, where I use the apps by hand as a customer, rider and restaurant. Only then does it reach production, and in production it starts in shadow.
Promotion to serve goes through a script with check, apply, status and undo steps. Once applied, a watchdog watches error rates and live canary comparisons and reverts the change by itself if anything looks wrong. Before the first production shadow release, I ran a zero-change proof: the new binary had to answer every route that was already serving byte-identically to the old binary.
When a security review finds a hole in the old code, the port does not copy it. Go fails closed (no zone means no access, a missing role means no access), and the fix also goes into Laravel through the same gate, so the two stacks never disagree on who may see what.
Trade-offs and what went wrong
Bug-for-bug compatibility has a cost. Shadow mode pushes Go to reproduce Laravel exactly, including Laravel's mistakes. When the comparison exposes a real bug in Laravel, I fix Laravel first and let Go match the fixed version. Otherwise the shadow would either keep reporting mismatches or bless the bug.
The copy shortcut mostly did not pay off. Copies worked well for endpoints that apps poll constantly. For the menu and store pages, requests were sparse enough that copies expired between visits, so most traffic still ended up at Laravel. The counters made this obvious within a day. The fix was to compute those answers natively in Go from the database.
An "off" route hid a working one. At first, lookup returned the first matching endpoint even when it was switched off. Registering a new native endpoint without listing it blocked an older copy of the same path, and that traffic quietly went back to Laravel. Nothing errored. The share of traffic Go answered just dropped. Off now falls through, and a separate behind mode handles comparison without taking traffic.
The watchdog reverted a good change. I promoted a group of home-screen lists, and the watchdog rolled them back within minutes. Its canary compared lists position by position. Laravel's own order among equally ranked stores depended on the request's coordinates and on which request had built its cache, so two correct answers could differ in order. The canary now checks that both lists hold the same set, that every item's fields match, and that items move only a little. The second promotion stayed.
Measuring the wrong thing. Early numbers on Go's share of traffic were misleading. Since-restart counters missed history, night traffic skewed the share, and my own cache warmers counted as user traffic on Laravel. I now read the share from the metrics history over full days and exclude the warmers.
An nginx regex that did nothing. A path regex used quantifier braces without quotes, which nginx rejects. My reload command piped the config test into another command, which hid the failing exit code, so the reload ran anyway. nginx kept the old config, so nothing went down, but the new routes were silently not live. Test and reload are now chained on the exit code, and regexes with braces are always quoted.
Many hands in one tree. I lean heavily on AI coding agents for porting, often several at once, each owning its own files. That is fast, but a half-finished file from one agent can break the shared build for everyone. Releases are therefore built only from a staged tree that contains finished, reviewed work. The shadow comparison, the write diffs and the gate are what make that pace safe.
What I would do differently
I would skip the copy layer and go straight to native implementations for anything that is not polled. The copies added a second caching system that I later had to remove.
I would build the write-diff harness before porting the first write, not partway through. Reads are easy to trust because shadow mode proves them on live traffic. Writes are where the risk is, and they need their own proof from day one.
I would set up least-privilege database users at the start. Splitting read and write users, then narrowing grants to columns after the fact, meant chasing old grant files that kept reintroducing broad access.
I would add timing telemetry to the apps early. Server-side numbers show Go is much faster than Laravel for the same answer. But I do not yet have real-phone measurements, so I cannot honestly say how much faster the app feels on a cheap phone on mobile data, and that is the number that matters.
What is next
Most of the busy app routes now run in Go. The operations console already runs on a Next.js app over a Go admin API. What remains on PHP is the hardest part, by design:
- Money paths. Order placement, payment callbacks, wallet, refunds and payouts move last. Each one sits behind its own switch, which starts off and is proven with a replay harness against real history. That work is described in the ledger case study.
- Jobs and workers. The Go scheduler and worker are running in the test environment. The remaining scheduled jobs and queue workers will move job by job, using the owner switch.
- The restaurant web panel, which is being rebuilt in Next.js on a Go API.
- Turning Laravel off. After that, the fallback path itself goes away. Only then will I consider changing the database. Changing the language and the database at the same time would remove the one thing that made this migration safe: the ability to compare against a system that already works.
Get new posts by email
One email when something new goes up. No tracking pixels, unsubscribe in one click.