Building GoLibora: what a study-library SaaS taught us about multi-tenancy
GoLibora runs 100+ study libraries on one codebase. The decisions that made that possible — and the ones we would make again — from seat modelling to tenant isolation.
GoLibora is our own product: a SaaS platform that runs Indian study libraries — the reading rooms where students book a seat by the month to prepare for competitive exams. Today it runs 100+ libraries and 1,000+ seats from one codebase. This is a write-up of the engineering decisions that made that workable, because most of them generalise to any multi-tenant product.
The domain looks simple. It is not.
A study library sells one thing: a seat, for a period of time. But every owner sells it differently — full-day seats, morning and evening shifts, hourly slots, monthly passes, and combinations of all four across branches. The first modelling decision was the one everything else stood on: the seat is the unit of business, not the room. A seat belongs to a branch, carries its own slot-wise pricing, and a booking is a claim on a seat for a slot and a period.
- Occupancy becomes a query, not an estimate from a wall chart
- Revenue per seat — the number owners actually run the business on — falls out for free
- Double-allotment is structurally impossible rather than procedurally discouraged
- A renewal is just another claim on the same seat, so history is never overwritten
Multi-tenancy: shared schema, scoped queries
At a few hundred rupees a month per library, per-tenant databases and per-tenant deploys were never on the table — the economics demand one deploy serving everyone. We went with a shared schema and tenant scoping enforced at the query layer, so a request can only ever see its own library's rows.
// Every tenant-owned model carries this scope. Forgetting a WHERE
// clause is a bug you write once; a global scope makes it a bug
// you cannot write at all.
protected static function booted(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where('library_id', app(TenantContext::class)->id());
});
}Branding is tenant data, not configuration. Each library gets its own subdomain and its own branded public site out of the same codebase — the free website turned out to be one of the strongest selling points, and it cost us one wildcard DNS entry and a theme table.
Payments: UPI-first, idempotent always
Fees here are small, frequent and overwhelmingly UPI. We integrated UPI, Razorpay, Stripe and PhonePe behind one internal payments interface, and made a rule that has never been broken: every gateway webhook is processed idempotently. Gateways retry; networks duplicate; a fee credited twice destroys an owner's trust in every number on the dashboard. Each webhook carries an event key, and applying the same key twice is a no-op by design.
GST was the other non-negotiable. Invoices split CGST and SGST correctly on every receipt, because the alternative is an owner's accountant finding out at filing time — the most expensive possible moment.
QR attendance and the parent portal
Attendance by QR check-in sounds like a gimmick until you see what it feeds: parents get a portal showing attendance and dues in real time, which quietly removed the single largest category of front-desk phone calls. The lesson we keep re-learning: the feature is rarely the capture; it is what the captured data lets someone stop doing.
What we would do again
- Model the smallest billable unit first — everything reports off it
- Enforce tenancy in one place, at the query layer, and test it adversarially
- Treat webhook idempotency as a founding rule, not a hardening task
- Make branding tenant data so every customer feels like the only customer
- Run the product ourselves before selling it — operating GoLibora is where half the roadmap comes from
A multi-tenant product is a promise that no tenant will ever know the others exist — in data, in branding, and in performance.
Comments
Be the first to comment
Every comment is read before it appears.