Laayo Blog

Laayo's tech stack, part one: the foundation

The servers, edge, databases and deploy habits that every order on Laayo runs through, and why we chose a boring, well-understood base before anything clever.

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

When you open Laayo in Birtamod or Damak and tap a restaurant, a lot happens in a few hundred milliseconds. The app asks for nearby stores, the server works out which ones deliver to your exact pin, prices are calculated, a rider’s position streams in, and a partner’s tablet starts ringing when you place the order.

This series walks through the systems behind that, layer by layer. We are a small team building for towns that the big platforms tend to skip, so most of our decisions come from one question: what gives customers, riders and partners a fast, reliable app without needing a large operations team to keep it alive?

Part one covers the foundation. Later parts cover the Go services we put on the hottest paths, realtime updates, the native apps and how we watch all of it in production.

How a request reaches Laayo: clients go through the edge and nginx, hot paths are answered by Go, everything else by the Laravel monolith, backed by MariaDB, two Redis roles, search and analytics stores.
How a request reaches Laayo: clients go through the edge and nginx, hot paths are answered by Go, everything else by the Laravel monolith, backed by MariaDB, two Redis roles, search and analytics stores.

The shape of the system#

Laayo has five kinds of clients:

  • the customer app on iOS and Android,
  • the rider app, which riders keep open for their whole shift,
  • the partner app that restaurants and shops use to accept and prepare orders,
  • the website at laayo.com.np,
  • and the admin panels our operations team uses on the web and on their phones.

All of them talk to one API. Behind that API sits a single Laravel application, a set of small Go services that take over the busiest requests, and a handful of data stores, each with one clear job.

We run on a deliberately small footprint: a few well-provisioned machines, off-site backups and a separate staging copy. That sounds modest, and it is on purpose. Fewer moving parts means one place to look when something is slow, one set of logs and very few network hops between services. It also means we have to be disciplined about what runs on it, which shows up in almost every decision below.

The edge#

Every request first reaches our edge provider. It terminates TLS close to the user, absorbs abusive traffic before it reaches us, and caches static files like images and app assets. For API calls it mostly passes traffic straight through, because almost everything in a food delivery app is personal or changes by the minute.

Behind the edge, nginx is the front door on our own server. It does three jobs:

  1. Routing. A small list of paths that we have rebuilt in Go goes to the Go services. Everything else goes to Laravel.
  2. Limits. Per-IP and per-token rate limits protect endpoints that apps call often, such as rider location updates.
  3. Fallback. If a Go service is down or returns a gateway error, nginx quietly retries the same request against Laravel. Customers never see the difference, which is what lets us move fast on the Go side.

Laravel, the monolith we keep#

The core of Laayo is a Laravel application on PHP 8.3, running under PHP-FPM behind nginx. It is big, with thousands of routes across the apps, the admin tools and the partner web panel. It owns orders, payments, coupons, wallets, rider payouts, support tickets and the admin tools that run the business day to day.

We get asked why we have not rewritten it. The honest answer is that a monolith is a very good place to keep business rules. When a coupon, a delivery fee and an E-Payment refund all need to agree, it helps that they live in one codebase with one database transaction. Rewrites are where those rules quietly break.

So Laravel stays, and we make it faster where it counts:

  • Fewer queries per request. One configuration step used to run 26 queries on every app config call. It now runs one. Store lists dropped from hundreds of queries to a few dozen after we batched schedules, configs and modules.
  • Cached schema checks. The code checks whether optional tables and columns exist in many places, more than 600 of them. Each check was a database round trip. We now answer them from an in-memory cache, which took one of the rider’s busiest screens from about 140 ms to about 45 ms.
  • Slimmer responses. The apps never read dozens of fields that store lists used to send. Dropping them made the biggest list responses about a third smaller.

The one thing we cannot make much faster is the cost of starting the framework for each request. A trivial Laravel API call costs us around 170 ms before it does any real work. That number is the reason part two of this series exists.

Data: one source of truth, two kinds of Redis#

MariaDB is our source of truth for everything that matters: orders, items, stores, users, payments. We keep the whole working set in memory, and in practice almost every read is served from the buffer pool rather than disk. Indexes get added when a slow query shows up in monitoring, not before. A customer list that took 51 seconds in the admin panel went to 0.06 seconds with one missing index, which is a good reminder that boring fixes still win.

Redis runs in two separate roles, and the split matters more than it sounds.

RoleWhat lives thereWhen it is full
Liverider positions, presence, queues, locks, realtime bridgesmust never silently drop data
Cachesaved API responses, menu and store copies, computed listsevicts the least recently used keys

Before the split, one Redis did both. A burst of cache writes during dinner could push out a rider’s live position or a queue entry. Now a cache flood only ever evicts other cache entries. Positions and jobs are safe.

We also run Meilisearch for dish and store search, so “briyani” still finds biryani and “mo:mo” finds momo, and ClickHouse for high-volume logs and metrics that do not belong in the main database.

Queues with lanes#

Plenty of work does not need to happen while the customer waits: notifying riders about a new order, recording GPS trails in bulk, generating WebP copies of images, calculating fuel summaries.

That work goes onto queues, and the queues have lanes. Order-critical jobs like rider notifications and reminders go on the default lane, which has dedicated workers. Heavy background jobs, mainly GPS ingestion that arrives in batches of a thousand rows, go on a low lane with its own worker. A flood on the low lane cannot delay an order notification, because the default workers never pick up low-lane jobs.

Workers are long-running processes managed by the operating system. They restart automatically if they crash and recycle themselves every hour, so a slow memory leak in one job cannot build up over days.

Images#

Restaurants upload photos straight from their phones, so we used to serve multi-megabyte PNGs to customers on mobile data. A background job now makes a resized WebP copy of every store, product, banner and category image, sized for how it is shown. nginx serves the WebP copy when one exists and falls back to the original when it does not. Apps did not need an update, and image-heavy screens got much lighter overnight.

How a change ships#

The part of the foundation we are most strict about is not a technology. It is the path every change takes to production.

How a change ships: build on tests, compare outputs, check on staging with real data and outgoing messages blocked, deploy with a script that backs up first, then watch production. Every deploy has a rollback script.
How a change ships: build on tests, compare outputs, check on staging with real data and outgoing messages blocked, deploy with a script that backs up first, then watch production. Every deploy has a rollback script.
  1. Tests first. We run a full second copy of the backend with its own database. Every change is built and checked there first.
  2. Compare, do not trust. When we change how something is computed, we compare the old and new output, byte for byte where possible. “It looks right” is not a check.
  3. Staging with real shapes. A separate staging copy runs on a recent copy of production data, with every outgoing channel blocked: no SMS, no email, no push notifications, no payment callbacks. Real data finds bugs that test data never will, and the block means a test can never message a real customer.
  4. A script, not a session. Production changes go out through a deploy script that takes a backup, does a dry run, applies the change and runs checks. Each one comes with a matching rollback script.
  5. Watch it land. After a deploy we watch the dashboards and crash reports described in part five. PHP on our server picks up new code within about a minute, so we wait for that before checking, instead of declaring victory on a half-updated server.

Most of our changes can also be switched off without a deploy, through feature flags in the admin panel or per-endpoint modes on the Go side. Being able to turn something off in seconds is what makes it safe to ship often.

Why boring#

None of this is exotic. A monolith, a relational database, Redis, queues and a careful deploy path are the same pieces many teams start with. That is the point. When an order is stuck at 9 pm in Damak, we want the problem to be in a place we understand well, with logs we know how to read.

The interesting work happens on top of this base, where we have measured a real bottleneck and replaced one piece of it at a time.

What’s next#

In part two we look at the biggest bottleneck we found, the fixed cost of every Laravel request, and how we moved the busiest API calls to Go without a big-bang rewrite, by running Go in the shadow of Laravel and comparing every answer before trusting it.

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

InfrastructureLaravelMariaDBRedis

Laayo Engineering

Backend, apps and infrastructure

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