DEV Community

Cover image for Database-per-tenant in Laravel: 6 lessons from running a multi-tenant SaaS

Database-per-tenant in Laravel: 6 lessons from running a multi-tenant SaaS

We build BytePhase, repair shop management software used by 2,000+ repair businesses in 32+ countries. Every customer account gets its own database. Here's what that choice taught us. None of it is exotic, but most of it we learned the hard way.

The setup

  • Laravel + stancl/tenancy, database-per-tenant mode.
  • One central database holds tenant metadata, subscriptions and plans.
  • Each tenant gets a tenant_{id} database with ~120 tables: tickets, invoices, inventory, customers and so on.
  • Tenants are identified by subdomain.

The pitch for database-per-tenant is isolation: a missing WHERE tenant_id = ? can't leak one shop's customers into another shop's screen, because the other shop's rows aren't in the database at all. That holds. The cost moves elsewhere.

1. Queued jobs have no tenant until you give them one

An HTTP request finds its tenant from the subdomain. A queued job doesn't: by the time a worker picks it up, there's no request at all. The rule we now enforce everywhere:

public function handle(): void
{
    tenancy()->initialize($this->tenantId);
    // ... the actual work
}
Enter fullscreen mode Exit fullscreen mode

Pass the tenant id into the job's constructor, initialise tenancy first in handle(), and never rely on "whatever tenant was active when this was dispatched". Without this, a job can run against the wrong database, or the central one, and nothing tells you.

2. Cache keys are shared unless you make them not

Redis doesn't know about your tenants. Cache::remember('settings', ...) in tenant A, then the same key in tenant B, and B sees A's settings. stancl/tenancy can prefix cache tags per tenant, but any hand-rolled key (rate limiters, locks, Cache::forever calls in a service class) has to include the tenant id. We treat a cache key without a tenant prefix as a bug in code review.

3. "Tenant" is not always "business"

Our customers asked for something database-per-tenant doesn't give you for free: several billing identities inside one account. One owner might run two shops with different tax numbers, logos, invoice number series and bank details, while sharing staff and customers.

So inside each tenant there's a second level, a business:

  • The frontend sends an X-Business header.
  • Middleware resolves it to the current business.
  • A global Eloquent scope filters business-owned models (invoices, stock, jobs).
  • business_id is stamped on create and can never change.
  • Documents always print with their own business's identity and numbering, never the one in the current header.

Lesson: decide early which tables are tenant-wide (customers, staff, masters) and which are per-business, and write it down. Retrofitting the scope later meant touching every query that assumed "one shop per database".

4. Migrations run N times, so they must be boring

A migration that takes 2 seconds takes 2 seconds × every tenant. Worse, one tenant with odd data can fail halfway through a batch. What works for us:

  • Schema migrations only. Settings and data rows go through idempotent seeders, never data migrations.
  • Never edit a migration that has already run anywhere.
  • Every migration must be safe to re-run on a tenant where it half-applied.

5. Per-tenant settings need defaults that can roll forward

New settings keys land in a definitions class with defaults. A seeder ensure()s them on every existing tenant/business. Code always reads through a helper that falls back to the default. That way a new feature never crashes on an old tenant that hasn't been backfilled yet.

6. Isolation is a property you test, not one you assume

Database-per-tenant makes cross-tenant leaks rarer, not impossible. Shared caches, shared queues, file storage paths, and anything that touches the central connection are all ways back in. We keep a short checklist for every feature:

  • Does it run in a queue? → initialise tenancy.
  • Does it cache? → tenant-prefixed key.
  • Does it store files? → tenant-scoped path.
  • Is it business-scoped? → global scope + immutable business_id.

Tenant isolation checklist for a Laravel multi-tenant SaaS: initialise tenancy in queued jobs, tenant-prefixed cache keys, tenant-scoped file paths, immutable business_id


If you're building something similar, or you run a repair shop and want to see where all this ends up, the repair ticket workflow is the part of the product every one of those lessons touched. Happy to answer questions in the comments.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

On lesson 3, if invoices currently read the customer from the shared row at print time, I'd add one rule to the checklist: copy the customer's name, address and tax details onto the invoice when it's issued. Because customers are tenant-wide and invoices are per business, someone correcting a customer's address for one shop would otherwise change what an older invoice from the other shop prints. It's the same reasoning as printing each document with its own business identity, applied to the other party on the page.