Multi-Tenancy Across Six Products: What We Wish We'd Done From Day One

S
Samuel Kimani
May 07, 2026 3 min read

The lazy decision is also the wrong one

The lazy default when starting a multi-tenant SaaS is "single column, tenant_id everywhere". It works. It scales to a few dozen tenants. It also calcifies into the most expensive refactor on your roadmap because every query, every job, every cache key, and every URL needs tenant scoping bolted on. We did this on the first VE.KE product and have been paying the bill ever since.

Per-tenant credentials, not per-product

Every M-Pesa-integrating product (Parcel, Tikiti, Konnekted, VePay, POS) handles tenants with their own M-Pesa shortcode, paybill, passkey, and Daraja consumer key. We centralized this in a tenant_payment_profiles table that any product can read from, scoped by tenant. One source of truth means rotating a passkey is one update, not a hunt through six codebases.Same model for SMS sender IDs, eTIMS PINs, and bank account numbers. The "credential vault per tenant" pattern is one of the few decisions we'd make sooner if we started over.

Single database, multiple schemas, until you can't

We run one MySQL with tenant_id columns for all but the largest tenants. The hard cap is around 50 active tenants per product, past that, the noisy-neighbor problem (one tenant's reporting query taking down everyone else's logins) starts hurting. The migration path we use: move the noisy tenant to a dedicated database with the same schema, route queries via a tenant resolver in the Laravel database manager, and leave the small tenants in the shared DB.The schema is identical between shared and dedicated databases. That sounds obvious; it isn't. The temptation to "optimize" the dedicated version eventually means tenants can't move back, and you're stuck. Resist.

Domain routing is cheap; do it early

Every tenant gets a subdomain (acme.tikiti.co.ke) plus the option to bring their own (book.acmeevents.com) via CNAME. A simple middleware resolves the request host to a tenant on every request and stashes it in the container. Once that's in place, branded URLs become a checkbox feature instead of a project.The same middleware controls what subset of features a tenant sees. We've found tenants want fewer features than the product team wants to ship them, domain routing lets us turn off the noise per-tenant without forking the codebase.

Background jobs are the trap

Every queued job needs the tenant context. We learned this the hard way when a payment reconciliation job ran with no tenant set and tried to reconcile every tenant's M-Pesa account against a single tenant's shortcode. Nothing bad happened, the queries returned empty, but it was a silent class of bug waiting to happen.The fix is structural: every job constructor takes a Tenant. Every job middleware (we use the Spatie tenancy package) re-establishes the tenant in the container before handle() runs. No method on a model is allowed to read tenant_id from a request, only from the resolved tenant. If you can enforce this from day one, you'll avoid an entire category of incidents.

Need software built?

Tell us what you need. We respond within 24 hours with a realistic quote.