I have spent the last months building a ride-hailing platform — riders, drivers, a live operations desk, cash and wallet payments, self-hosted maps. It is in UAT, not production; nothing below describes real scale. What it does describe is a set of architecture decisions I can point at a number for, and a few that a number reversed.
This is the general shape of the system and the five decisions that were worth writing down.
The shape
Six moving parts, deliberately separated along the lines where they change at different speeds.
Flutter rider app ─┐
Flutter driver app ─┤ shared Dart package
Flutter vendor app ─┘ (models, API client, design system)
│
│ REST + WebSocket
▼
┌──────────────────────────────────────────────┐
│ Laravel API Horizon (queues) │
│ Filament back office Reverb (WS) │
│ Go telemetry sidecar │
└───┬──────────┬──────────┬─────────────┬───────┘
│ │ │ │
Postgres Redis OSRM Photon TileServer GL
(records) (GEO + (routing) (geocoding) (map imagery)
queues +
cache)
▲
│
Next.js operations console
(live map, SOS, interventions)
The backend is Laravel: the REST API, a Filament admin panel, Horizon for queues, Reverb for WebSockets. One deployment per country, each with its own database and its own worker containers, all sharing one Postgres instance.
The operations console is a separate Next.js app. It is the safety desk — a live map of drivers, the SOS feed, active trips, interventions.
Three Flutter apps — rider, driver, and a small vendor app for shops that sell drivers wallet credit for cash — consume a shared Dart package holding the models, the API client and the design system.
A Go sidecar handles GPS presence, off by default. That one has a story.
Everything below is the reasoning behind a piece of that picture.
How a ride actually happens
The central mechanic, because the rest of the post assumes it.
A rider requests a trip. The pickup point resolves to a service area — a city, effectively — which is recorded on the trip and from then on scopes its fare table, its surge, its match radius and how long a driver has to answer an offer. Nothing about pricing is global.
A matching job then runs a Redis GEORADIUS … ASC around the pickup and walks the results first-eligible-wins, so offers go strictly nearest-driver-first. One subtlety worth the design: drivers can dial their own willingness to travel, so the scan runs to the platform ceiling rather than the city default — but every candidate returned is then held to the radius that driver personally chose. Widening the scan makes a driver visible for work they could not previously see. It never lets them jump ahead of someone closer.
The driver accepts, and the trip walks a state machine: accepted → captain_en_route → arrived → in_progress → completed. Accept locks only the target trip row, which is not enough on its own, so there is a partial unique index underneath it:
CREATE UNIQUE INDEX one_active_trip_per_driver
ON trips (captain_id)
WHERE captain_id IS NOT NULL
AND status IN ('accepted','captain_en_route','arrived','in_progress');
That is the data-layer guarantee that a driver cannot hold two active trips, independent of whatever the application layer believes.
Throughout the ride, every GPS fix writes a route point. When the driver taps complete, fare finalization reads that trail and prices the trip from it. The trail is the fare — which is the reason it is still the thing I will not move out of PHP.
1. 97% of a GPS ping was framework overhead — and a rewrite was still the wrong first fix
A driver's app posts its position every few seconds. That endpoint is the highest-volume thing in the system by an order of magnitude, so the proposal was the obvious one: get GPS pings out of the PHP HTTP kernel entirely. A Go service, a WebSocket RPC, writes straight to Redis.
Before building it, I measured the existing endpoint. Median over loopback:
| Layer | Median |
|---|---|
| nginx serving a static file, no PHP | 0.7 ms |
| Laravel boot + routing | 37.8 ms |
| + auth middleware | 43.8 ms |
| full ping | 62 ms |
And inside that 62ms, the work the endpoint actually exists to do:
Redis geoadd + hset + expire
|
0.16 ms |
| cache write for a broadcast throttle | 0.11 ms |
| Postgres active-trip lookup | 0.47 ms |
| queue an event | 0.85 ms |
| total real work | ≈1.6 ms — 2.6% of the request |
So the instinct was right. Ninety-seven percent of a ping is framework overhead, and at any real fleet size presence has to leave PHP.
It was also, right then, the wrong thing to build, because the actual bottleneck was one line of config: pm.max_children = 5. The stock PHP-FPM default, never tuned, on an eight-core box. Five concurrent requests for the entire API. At 62ms that is roughly an 80 req/s ceiling — but the throughput number is not the hazard. The hazard is head-of-line blocking: five slow I/O-bound requests, a routing call and a report query among them, can stall every other endpoint in the system. A rider's booking queues behind GPS pings.
Raising it to 24 (sized from measured worker RSS, not from a blog post) plus config and route caching took the ping to 55ms p50 and moved the plateau from worker-bound to CPU-bound. Days of Go work, deferred by an afternoon.
The measurement also inverted a confident guess of mine. The per-ping Postgres lookup — the obvious thing to cache, the thing I would have optimised first — is 0.47ms. Caching it would have been effort spent on 0.7% of the request.
Caveat, and it matters: these are numbers from one 15GB/8-core box also running Postgres, Redis, the routing engine, the geocoder, the tile server and the queue workers. The ratios travel. The absolute milliseconds do not.
The Go service exists now, proven end to end, behind a server-side driver that still defaults to plain HTTP. The app-side switch is a build flag rather than a server setting, deliberately: a cohort rollout has both kinds of build in flight at once, so no server toggle would be right for the whole fleet. What moved into it is presence only. The GPS trail stayed in PHP, because the trail is the fare — fare calculation reads those points the moment a driver taps complete — and the ordering, clock-skew and teleport guards that make it billable are money logic I refuse to maintain in two languages. The trigger to switch it on is a number, not a feeling: p95 above 150ms, or a sustained FPM listen queue, or more than 150 concurrent drivers.
2. The bug only an end-to-end test could find
When that Go service first went to UAT, it answered ok. The container was healthy. The logs were clean. The WebSocket RPC round-tripped. And dispatch could not see a single driver.
The service defaulted REDIS_DB to 0. That deployment runs on database 2.
It was writing perfectly-formed, correctly-prefixed keys into a Redis database nothing reads. Every component test passed on both sides throughout, because each half was right about its own half. The Go tests asserted it wrote the keys it meant to write. The PHP tests asserted it read the keys it meant to read. Nothing in either suite knew they were pointed at different databases.
REDIS_DB and REDIS_PREFIX now have no defaults, and the service refuses to start without them. They are the entire contract with PHP, and being wrong about either is invisible from inside the service. Both are logged on the first line at startup.
The general rule I took from it: when a config value is a contract with a different process, a default is not a convenience. It is a way of failing silently in the one environment you did not test.
3. A wrong market constant does not throw — it produces a plausible number
The platform runs in more than one country. Currency, dial code, map centre, the OSM extract used for routing — all of it lives in config rather than in constants, so a new market is a config change instead of another pass over sixty files.
That part worked. What bit, repeatedly, was the fallbacks.
- The currency fallback was a hardcoded string in ten files in the mobile code. One market holds money in whole units, another in thousandths. A fare of 3250 rendered as "3250" of the wrong currency instead of "3.250" of the right one — correct digits, a thousand times the money.
- A third currency was in neither minor-unit table, server or client. An unlisted code falls back to two decimals, so a driver's day of 164,000 was drawn as "1640.00". Plausible. Off by two orders of magnitude.
- Routing two points in one country against another country's OSM extract returns
code: Okwith a distance of zero. A fare of zero. - The wallet top-up ladder was
5 / 10 / 20 / 50, which is banknote-shaped only while a ride costs a few of them. In a market where it does not, it offered four amounts that would not pay for a kilometre — and since all four are valid numbers, no validation filtered them out.
None of these threw. None of them logged. Every one produced an answer that looks like an answer.
There is a related trap on the server. The test suite read config('fare.*'), which reads environment variables, which on my machine still held the previous market's numbers. So after changing the config defaults, the suite stayed green while measuring the old currency. The fix was to pin those variables in phpunit.xml, so the suite asserts fixed numbers regardless of whose laptop it runs on.
Test the arms. A market-specific constant does not fail when it is wrong for the market, so a green suite proves nothing unless something asserts the branch you are not currently running.
4. Polling plus streaming means the poll hides the stream dying
The operations console gets its live map two ways at once: a REST poll for a correct snapshot, and a WebSocket stream to move pins between polls. That combination is the right design — the poll is the floor, the stream is the smoothness — and it has a specific failure mode I did not anticipate.
When the stream breaks, nothing looks broken.
Driver positions are published to geohash-cell channels. The console computes which cells its viewport covers and subscribes to each. The geohash encoder is implemented twice — once in PHP, once in TypeScript — with no shared source of truth, and the two must agree on precision exactly. If either side changes it, the console subscribes to cells the backend never publishes to.
Pins do not disappear. The poll keeps rendering them. They just stop moving between polls. The symptom is "the map feels a bit laggy," which is not a bug report anyone files, and nothing logs it.
The same family, elsewhere in the stack:
- No queue worker running. All console events go through a queue, so they are enqueued and never delivered. Polling still works, so the map looks alive while SOS alerts arrive up to twenty seconds late.
-
A renamed field. The TypeScript interfaces are hand-written, not generated from the backend. Rename a field server-side and the console still compiles; the field just reads
undefinedat runtime. - The WebSocket server answering 101 and then 500. A config type mismatch made it accept the upgrade and immediately error on the same socket. Every health check that stops at the status line reported healthy. That one hid through several rounds of "verified working" before anyone read the first actual frame.
What I would do differently: the seams between two repos with no shared types need to be able to go red. Generate the client types, or at minimum write a contract test that fails loudly when a shape drifts. Graceful degradation is a good property and a terrible diagnostic.
5. Tiles, routing and search are three different services
The most common misunderstanding about the map, including mine early on: a tile server cannot answer "find me a place called X." It serves imagery.
| Concern | Service |
|---|---|
| The map you see | TileServer GL |
| Fare estimate and route line | OSRM |
| "Find me a place called X" | Photon |
Only the tile server is consumed directly by a client. Routing and search are both proxied by the API, which keeps them off the public internet and keeps rate limiting, caching and auth on our side. They also live in their own compose projects, because rebuilding a routing graph is a download plus a long preprocess — not something to trigger by restarting the API.
Two things worth stealing from this:
The tile URL is never compiled into the app. The mobile clients fetch a map config endpoint at startup: style URL, attribution, default centre, and a flag for whether place search is even enabled. A hardcoded tile URL turns a host move into an app-store release on two platforms. Place search is optional infrastructure — with no geocoder configured the API answers a distinct "search disabled" code, and the client hides the search box rather than showing one that always fails.
The routing engine serves from shared memory. The router attaches to a graph loaded by a separate datastore process rather than opening the files itself. That indirection is the point: new road speeds can be applied to a live router without restarting it and dropping every request in flight. Verified rather than assumed — 60 requests through a reload, 60 answered.
The part I did not expect to matter: how the documentation is written
A system this spread out needs a wiki, and my first attempt at one failed in an instructive way. Its "read this first" page stated a test count, a branch name and two commit hashes. Six days later every one of those was wrong, and nothing announced it. The page looked exactly as authoritative as the day it was written.
So the wiki is now split in two, and the split is enforced.
Generated pages are written by a script that reads the repositories: the service and port map, the route table with its middleware, the database schema, dependency advisories, which repo is ahead of its remote. A commit hook refreshes the cheap parts on every commit and the expensive parts once a day. Nobody types these facts, because a machine can check them.
Hand-written pages hold only what code cannot tell you: why a decision was made, what breaks if you change it, and which traps have already cost somebody an afternoon. Everything in this post came from those.
The rule that makes it work: a generated page that cannot verify something prints COULD NOT VERIFY and the error. It never falls back to the last value it knew. Serving last week's answer while looking like today's is the exact failure the whole arrangement exists to avoid — which, now that I write it down, is the same failure as the laggy map, the plausible currency and the healthy container writing to the wrong database.
That turned out to be the theme. Almost nothing in this system broke by falling over. It broke by continuing to look fine.
Top comments (0)