RZ

Preparing your experience

Back to Blog
System Architecture

Designing a Multi-Tenant SaaS Architecture in Laravel: Lessons from Production

May 20, 20258 min read
LaravelSaaSArchitectureMulti-tenancyPHP

When we built the multi-tenant version of our B2B job marketplace, the tenancy strategy wasn't a detail we could defer. It's one of those foundational decisions — like choosing a primary key strategy or a queueing system — where the cost of getting it wrong doesn't show up in week one. It shows up eight months later, when a recruiting agency asks why their competitor's job postings appeared in their export, or when a routine schema migration takes down every tenant at once because they all share one table.

This post is about the three tenancy strategies we actually evaluated, why we picked database-per-tenant over the more commonly recommended shared-schema approach, and what that decision costs us operationally today — because every tenancy strategy has a bill, and the question is only which bill you'd rather pay.

The Three Options We Evaluated

1. Shared Database, Shared Schema

All tenants live in the same tables, filtered by a tenant_id column on every query. This is the cheapest option to operate — one database, one connection pool, one set of migrations to run, ever. It's also the option where isolation depends entirely on developer discipline: forget a WHERE tenant_id = ? clause on one query, in one code path, and you've leaked one recruiting agency's candidate pipeline to another. Given that our contracts with enterprise recruiting agencies included explicit data-isolation clauses, "depends on nobody forgetting a WHERE clause" was not a risk profile we could accept.

2. Shared Database, Separate Schemas

One schema per tenant, still inside a single database instance. This is a genuinely reasonable middle ground — it gives you schema-level isolation without the operational overhead of dozens or hundreds of separate database instances. The problem, for us, was tooling maturity. Laravel's Eloquent ORM doesn't handle schema-switching elegantly out of the box; you end up either forking connection configuration per request or leaning heavily on a package to paper over the gap. It's workable, but it adds a layer of "magic" between the ORM and the actual schema that made a few engineers on the team uneasy about debuggability.

3. Database Per Tenant

A fully separate database per tenant. This gives you the strongest isolation guarantee of the three — a bug in a query simply cannot reach another tenant's data, because that data doesn't exist in the same database at all. It also makes backup, restore, and per-client migration trivial to reason about: "restore this one client's data" is a well-defined operation, not a surgical extraction from a shared table. The cost is operational: connection pool management and migration orchestration across potentially hundreds of databases.

What We Chose and Why

We went with database-per-tenant, using the stancl/tenancy v3 package. The isolation guarantee was the deciding factor given our contractual obligations, but there was a second reason that mattered just as much in practice: with a 23-person cross-functional team simultaneously shipping AI-powered job matching and resume-parsing tooling on top of this tenancy layer, we needed tenancy to be invisible to the rest of the codebase. Feature teams building matching algorithms shouldn't have to reason about which schema they're querying — that's exactly the kind of cross-cutting concern that should live in infrastructure, not in every feature team's mental model.

The real cost of a tenancy decision isn't the initial implementation. It's how much every future feature team has to think about it. Database-per-tenant, done right, means most engineers never think about tenancy at all.

The Operational Reality

Isolation doesn't come free, and it's worth being honest about what it actually costs to run in production.

Migrations Across 200+ Tenant Databases

Running a schema migration against a single database is trivial. Running the same migration, safely, against 200-plus independent tenant databases — some large, some nearly empty — requires real orchestration. We built a Laravel Artisan command that runs migrations in batches of 20 tenants at a time, with automatic retry on transient failure and a Slack alert if any batch fails outright. The whole run, across all tenants, completes in under four minutes — fast enough that we run it as a normal step in our deploy pipeline rather than a special maintenance event.

php artisan tenants:migrate --batch-size=20 --notify-on-failure

# Simplified batch logic
foreach (array_chunk($tenants, 20) as $batch) {
    foreach ($batch as $tenant) {
        try {
            tenancy()->initialize($tenant);
            Artisan::call('migrate', ['--force' => true]);
        } catch (Throwable $e) {
            SlackAlert::send("Migration failed for tenant {$tenant->id}: {$e->getMessage()}");
        }
    }
}

Connection Pooling

You cannot keep 200-plus persistent database connections open per app server — you'll exhaust connection limits long before you exhaust anything else. We use lazy connections that are only established when a tenant is actually initialized for a request, combined with a 30-second idle timeout that closes unused connections. This keeps the steady-state connection count proportional to active tenants, not total tenants.

Strategy Isolation Migration complexity Operational cost
Shared schema Low (query discipline only) Low Low
Separate schemas Medium-high Medium Medium
Database per tenant High High (needs orchestration) Medium-high

Best Practices We've Settled On

  • Never let a feature team hand-roll tenant scoping — route every tenant-aware query through the tenancy package's context, so there's exactly one place isolation can break, not one place per feature.
  • Treat the tenant-migration command as a first-class deploy step, not an afterthought run manually by whoever remembers to.
  • Alert on migration failures per-tenant, not just in aggregate — a single failed tenant in a batch of 20 is easy to miss if you're only watching for "did the batch succeed."
  • Keep a central "control" database for cross-tenant concerns (billing, admin auth) completely separate from tenant databases, so a tenant database being wiped never has a chance of touching billing state.

Common Mistakes to Avoid

  1. Choosing shared-schema tenancy purely because it's cheaper to start with, without checking whether your contracts or compliance obligations actually allow that isolation level.
  2. Bolting a tenancy package onto an existing single-tenant codebase late, after dozens of queries have already been written assuming a single database connection.
  3. Under-provisioning for connection limits — 200 tenants times even a small persistent connection count per app server adds up fast.
  4. Forgetting that per-tenant backups need their own retention policy conversation with legal/compliance — "we back up the database" means something different when there are 200 of them.

What I'd Do Differently

Start with stancl/tenancy from day one, rather than retrofitting it onto code that was written assuming a single database. The package's central/tenant context-switching model is genuinely elegant once you're inside it — but that elegance only pays off if the codebase is built against it from the start. The AI matching and resume-parsing services on this project shipped without any tenancy-layer rework specifically because they were built against the tenancy context from their first line of code, not retrofitted afterward.

FAQ

Q: Doesn't database-per-tenant get expensive at scale?
It gets operationally heavier, not necessarily more expensive in raw infrastructure cost — many small tenant databases can share underlying compute. The real cost is the orchestration tooling you need to build, which is a one-time investment rather than a recurring one.

Q: How do you handle a tenant that outgrows a shared database instance?
Because each tenant already has its own database, migrating a single large tenant to dedicated compute is a matter of moving one database, not extracting rows from a shared table. This was, in fact, one of the strongest arguments for the approach.

Q: What about cross-tenant reporting or analytics?
That's the genuine downside of database-per-tenant — cross-tenant queries require fan-out rather than a simple join. We handle this with a separate, asynchronous ETL process that aggregates anonymized metrics into a reporting database, rather than trying to query 200 tenant databases live.

Monitoring a Fleet of Tenant Databases

Isolation solves the correctness problem, but it introduces a visibility problem: with 200-plus independent databases, "is the system healthy" stops being a single dashboard and becomes a fleet-monitoring question. We built a lightweight health-check job that connects to every tenant database on a rolling schedule, checking connection latency, disk usage, and replication lag where applicable, and rolls the results up into a single aggregate view rather than 200 separate ones.

The key design decision was to alert on aggregates and outliers, not on every individual tenant's raw metrics. A single tenant database running slightly slower than average isn't actionable information by itself — it's noise. A tenant database whose latency has doubled in the last hour, or one that's suddenly an outlier relative to its own historical baseline, is worth paging someone about. Getting this threshold right took a few iterations; too sensitive, and the on-call engineer starts ignoring alerts entirely, which defeats the purpose of alerting at all.

What We Track Per Tenant

  • Query latency percentiles, compared against that tenant's own rolling baseline rather than a fleet-wide average, since tenant size varies enormously.
  • Connection pool saturation, to catch a tenant whose usage pattern is quietly outgrowing the shared connection budget.
  • Storage growth rate, since a small number of large tenants tend to dominate total storage and are worth capacity-planning around individually.

This monitoring layer ended up being just as important to running database-per-tenant successfully as the tenancy package itself — the isolation strategy only pays off if problems in one tenant's database are caught before they escalate, rather than discovered when that tenant files a support ticket.

Conclusion

There is no tenancy strategy that's free — every option trades isolation for operational simplicity somewhere along the spectrum. For a platform with contractual data-isolation requirements and a large, actively-shipping feature team, database-per-tenant was the right trade for us: it pushed complexity into infrastructure tooling that a small group owns, and kept it out of the mental model of every engineer building product features on top.

Found this useful? Explore more articles on System Architecture.

All articles