Personal project · in production
FuratGo · Ride-Hailing Platform
A complete ride-hailing system — one Laravel API, a live ops console, three Flutter apps on a shared Dart core — live in Jordan, with two more markets as config.
Six repos · three markets configured · 97% of a ping was framework
The problem
A ride-hailing platform is not one product. It is a rider app, a captain app, a credit-sales app, an operator’s desk, and an API that has to keep all four honest about the same trip at the same moment — while a captain drives through a tunnel and the connection drops. Building any one of them is ordinary work. Keeping six repositories agreeing on one trip is the actual engineering problem, and it is the one nobody’s README warns you about.
What I built
The API is Laravel, domain-driven, with the trip as the aggregate root. Its lifecycle is an
explicit state machine — pending → searching → accepted → en route → arrived → in progress → completed — where each transition is a method that validates itself and stamps its own
timestamp, so an impossible move is a refused call rather than a bad row.
Matching runs off Redis GEO. Nearby online captains are offered the trip sequentially,
one at a time, with a reservation so two concurrent requests can’t both be asked, and a
per-offer timeout that advances the queue. That a single captain wins a contested trip isn’t
an assumption — app:concurrency-check forks real parallel processes against PostgreSQL and
asserts exactly one winner, and no overdraft on a wallet being debited from several directions
at once. The SQLite test database makes lockForUpdate a no-op, so the suite alone would have
proved nothing.
The fare is computed from the recorded GPS trail, not from the routing estimate. The estimate exists for the rider’s expectations; the trail is what actually happened. It is also why I rejected batching trip points through Kafka later: settlement reads those points the moment the captain taps complete, and a batch window puts a race between completion and the last point landing. The failure mode is silent under-charging, which is the worst kind.
Money is integer minor units of each record’s own currency, and EGP is stored with zero minor units on purpose. That disagrees with ISO, which says two — but this is a cash-first market where the rider hands over notes, nobody makes change in piastres, and the fare rounds to the nearest five pounds anyway.
The ops console (Next.js, MapLibre) is a safety desk, not a table. It is built around a ranked attention queue: live state is measured against operator-set thresholds — unmatched too long, sitting at the kerb, a captain gone dark mid-trip — and only what crosses one appears, worst first, with the intervention in the same pane that raised it. It scales by refusing to ask for what isn’t on screen: viewport-scoped queries, server-side clustering below zoom 11, and one geo query plus one pipelined hash fetch instead of a Redis N+1. Red is reserved for SOS and nothing else.
Three Flutter apps — rider, captain, and a vendor credit station — sit on a shared, deliberately headless Dart package. It contains no Flutter import at all: the apps own UI, the package owns the contract. Live updates reach the apps and the ops console through Centrifugo, a dedicated real-time server that replaced the original WebSocket layer. Two rules in the package are load-bearing. The live channel is an optimisation; REST is the truth — on reconnect the app re-reads the trip rather than trusting events it may have missed. And never retry a POST, so the retry interceptor is GET-only, because a duplicated settlement is money.
The vendor app is the one I’d defend hardest. The captain shows a QR and the vendor scans it, which looks backwards until you consider the alternative: a code the vendor displays is a bearer instrument, and whoever scans it first at a busy station walks off with the credit. Reversed, there is nothing to steal — redeeming costs the vendor money — and the captain’s name appears on the vendor’s screen before the amount is chosen, which is human verification for free.
The result
The platform runs in Jordan, with Egypt and Syria as config rather than forks: market geography and identity live in one file, service areas scope fares and match radius per city, and each app builds per market from one script. Maps are self-hosted across three separate services — tiles, routing, and place search — and mobile reads the tile URL from the API at startup, so moving a map host is a config change instead of an app-store release on two platforms.
The performance work is the piece I’m proudest of, because measurement inverted a confident
guess. A GPS ping cost 62ms; the work it exists to do — the Redis write, the active-trip
lookup, the queued broadcast — came to 1.6ms, about 2.6% of the request. The obvious thing
to cache, that Postgres lookup, was 0.47ms: optimising it would have been effort spent on 0.7%
of a request. What was actually throttling the platform was pm.max_children = 5, a stock
default never tuned, on an eight-core box — five concurrent requests for the entire API, with
a rider’s booking able to queue behind GPS pings. Sized from measured worker memory, that
became 24; the ping fell to 55ms and throughput now plateaus CPU-bound rather than
worker-bound. A Go service for the ping path exists and is deployed, off by default — the
right destination, and the wrong thing to have built first.
The six repositories are held together by a wiki that splits itself in two: pages a script generates by reading the code, and pages written by hand that only hold what code cannot say — why a decision was made, and what breaks if you change it. The split exists because the first version stated a test count, a branch name and two commit SHAs, and six days later every one of them was wrong while the page looked exactly as authoritative as the day it was written. Facts a machine can check should never be typed by a person. The hand-written half is mostly contracts between repos: the geohash precision implemented twice in two languages with no shared source of truth, and the SOS payload whose REST and broadcast shapes disagree on field names — both failures that are silent, and both now written down on the seam rather than inside either repo.