Khaled Ahmed
Home Blog Backend & Architecture
Backend & Architecture

Multi-Tenant SaaS in Laravel: Single DB vs Database-Per-Tenant

Khaled Ahmed 17 min read

Start with a single database and a tenant_id column. I have built and inherited enough Laravel SaaS products to say that plainly: most founders who ask me for a database per tenant do not need one in year one, and the ones who build it anyway spend their runway paying an operations tax instead of finding product-market fit. I am Khaled Ahmed, a full stack developer in Cairo with five-plus years and 39-plus shipped projects. Here is the whole decision, with the numbers behind it.

1. The Verdict First, Then the Justification

There are exactly three sane architectures for a Laravel SaaS, and only three:

  1. Shared database, shared schema. One database, one set of tables, every tenant-owned row carries a tenant_id foreign key. Isolation is enforced in application code.
  2. Shared database, separate schema. One PostgreSQL database, one schema per tenant. Isolation is enforced by the search_path.
  3. Database per tenant. One physical database per customer, possibly on shared or separate servers. Isolation is enforced by the connection.

My default is number one. I move to number three only when a specific, named, present-tense requirement forces it — not because a board member said "enterprise clients will want it".

The five conditions that legitimately override the default:

  • A contract that names it. A signed enterprise agreement or a public-sector tender that specifies physically separate storage. This is real and it happens, especially in Gulf government and healthcare procurement.
  • Data residency per customer. One customer's data must sit in Saudi Arabia, another's in Germany. You cannot satisfy that with one shared table.
  • Per-tenant restore. A customer needs to be rolled back to yesterday 3pm without touching anyone else. Doing that from a shared table is surgery; from a separate database it is a restore.
  • Extreme data skew. One tenant holds 60% of your rows and their queries are starving everyone else's.
  • Per-tenant schema divergence. Enterprise clients get custom columns or custom tables. Rare, usually a product mistake, but occasionally the business model.

If none of those five is true today, build single-database. You can migrate later — I will show you how in section 11 — and the migration is far cheaper than the eighteen months of operational overhead you would have paid in the meantime.

The uncomfortable truth: database-per-tenant does not make your app more secure. It moves the isolation boundary from your code to your connection resolver. If your tenant resolver has a bug, you leak an entire customer's database instead of a few rows. I have seen both failures. The second one is worse.

2. What the Three Architectures Actually Cost You

Shared database, shared schema

Every query needs a where tenant_id = ?. In Laravel you enforce this with a global scope so nobody has to remember. Migrations run once. Backups are one job. Adding a tenant is a single INSERT — sub-second, which means self-serve signup works with no provisioning queue.

The cost is that a single forgotten scope is a cross-tenant data leak, and your largest table is the sum of all tenants.

Shared database, separate schema (PostgreSQL)

This is the underrated middle option and almost nobody writes about it honestly. Each tenant gets a PostgreSQL schema; you switch with SET search_path. You get real namespace separation and per-tenant pg_dump without paying for a separate database handle per tenant.

The cost: it is PostgreSQL-only, your migration fan-out problem is identical to database-per-tenant, and PostgreSQL's catalog gets heavy well before you would expect. Thousands of schemas each holding forty tables means hundreds of thousands of catalog rows, and operations like pg_dump of the whole cluster or a naive \dt start to crawl.

Database per tenant

Cleanest mental model, highest operational bill. Migrations must run N times. Backups are N jobs. Restores are trivially per-tenant. Analytics across all tenants becomes a genuine engineering project rather than a GROUP BY.

DimensionShared DB, shared schemaSchema per tenant (PG)Database per tenant
Isolation enforced byApplication code (global scopes)search_pathConnection / credentials
Blast radius of one bugSome rowsOne tenant's tablesOne whole tenant DB
Tenant provisioning timeMilliseconds1–10 seconds5–60 seconds (often queued)
Migration cost at 1,000 tenantsOne run1,000 runs1,000 runs
Per-tenant point-in-time restorePainful, custom toolingStraightforwardTrivial
Cross-tenant reportingTrivial SQLHard (UNION over schemas)Hard (needs a warehouse)
Connection pool pressureLowLowHigh — see section 6
Data residency per tenantNot possibleNot possibleYes
DevOps hours per month (my estimate)2–56–1210–30
Good default forB2B SaaS, self-serve, < 5k tenantsMid-market PG shopsEnterprise, regulated, few large tenants

3. Should I Use One Database or a Database Per Tenant?

Answer the following six questions honestly. Score one point each time the description in the right-hand column is the one that actually fits you.

QuestionPoints to shared DBPoints to DB-per-tenant
How do customers sign up?Self-serve, credit card, instantSales-led, contract, onboarding call
How many tenants in 24 months?Hundreds to thousandsDozens to low hundreds
Average revenue per tenant?Under $200 / SAR 750 per monthOver $2,000 / SAR 7,500 per month
Does any signed contract mention data separation?NoYes
Do you need cross-tenant analytics in-product?Yes (benchmarks, admin dashboards)No
Do you have a DevOps person or budget for one?NoYes

Four or more on the right and database-per-tenant is defensible. Three or fewer and you are buying a problem. The pattern I see over and over in SaaS builds is a two-person team choosing database-per-tenant for a $49/month product with a target of 3,000 customers. That combination cannot work — the provisioning, migration and backup overhead per tenant exceeds the gross margin per tenant.

There is also a hybrid worth knowing: shared by default, dedicated on request. Everyone lands in the shared database; enterprise accounts get moved to their own. This is what most mature SaaS products actually run, and it is the reason section 11 on the migration path matters more than the initial choice.

4. How Do I Guarantee Tenant Data Isolation in Laravel?

You do not guarantee it with a trait. You guarantee it with a default-deny posture plus a test suite that tries to break it. Here is the full checklist of leak paths, in the order I find them in real codebases.

4.1 The global scope is the floor, not the ceiling

In current Laravel (the ScopedBy attribute has been the recommended registration style since Laravel 10 and remains so in Laravel 13, released 17 March 2026), the base setup looks like this:

<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (! app()->bound('currentTenant')) {
            // Fail closed. Never return unscoped rows.
            $builder->whereRaw('1 = 0');
            return;
        }

        $builder->where(
            $model->qualifyColumn('tenant_id'),
            app('currentTenant')->id
        );
    }
}

Note the whereRaw('1 = 0'). Most tutorials write if (tenant()) { ... } and silently return everything when there is no tenant. That is the single most common cross-tenant leak I get called in to fix. Fail closed, then explicitly opt out in the few central-admin queries that genuinely need all rows.

use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([TenantScope::class])]
class Invoice extends Model
{
    protected static function booted(): void
    {
        static::creating(function (Invoice $invoice) {
            $invoice->tenant_id ??= app('currentTenant')->id;
        });
    }
}

4.2 The seven places the scope does not reach

  • Raw queries. DB::table('invoices') and DB::select(...) bypass Eloquent entirely. The stancl/tenancy documentation says this plainly about its own single-database mode: the package can only scope the Eloquent abstraction, not low-level database queries. Ban raw queries outside a small, reviewed Reporting namespace.
  • Validation rules. Rule::unique('posts', 'slug') checks the whole table. Tenant A creating a slug that tenant B already used gets a validation error that reveals tenant B exists. You must write Rule::unique('posts', 'slug')->where('tenant_id', tenant('id')) every time, and the same for exists.
  • Unique indexes. A plain $table->unique('slug') is a global uniqueness constraint. It must be $table->unique(['tenant_id', 'slug']). This is a schema decision you cannot cheaply undo later — one of many reasons I treat database design as the highest-leverage hour of a SaaS build.
  • Route model binding. Route::get('/invoices/{invoice}') resolves via the model, so the global scope does apply — good. But the moment someone writes Invoice::withoutGlobalScopes()->findOrFail($id) in a controller "just to debug something", you have an IDOR. Grep for withoutGlobalScope in code review, every time.
  • Queued jobs. A job serialised with SerializesModels stores the model's key and re-resolves it on the worker, where there is no HTTP request and therefore no tenant context. Either pass the tenant ID explicitly into the job and re-bind it in handle(), or use a package that injects it into the payload for you.
  • Cache and Redis keys. Cache::remember('dashboard_stats', ...) with no tenant prefix serves tenant A's numbers to tenant B. Prefix every key.
  • File storage, exports and PDFs. Uploads landing in a shared storage/app/invoices/ with predictable filenames are a leak even if the database is perfect. Namespace the disk path by tenant and never serve files by guessable path. This overlaps heavily with the broader website security checklist.

4.3 The isolation test that actually catches things

Write one test, run it against every tenant-owned model, and make it part of CI:

it('never returns another tenant\'s rows', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    app()->instance('currentTenant', $a);
    $mine = Invoice::factory()->count(3)->create();

    app()->instance('currentTenant', $b);
    expect(Invoice::count())->toBe(0);
    expect(Invoice::find($mine->first()->id))->toBeNull();
});

Note that the test binds the same container key the scope in 4.1 reads. Drive the test through whatever actually sets tenant context in your app — if you are using stancl/tenancy rather than the hand-rolled scope above, swap those two lines for tenancy()->initialize($a) and tenancy()->initialize($b). A test that initialises tenancy one way while the scope reads another is a test that passes without proving anything, which is worse than no test at all.

Then add a smoke test that hits every route as tenant B with tenant A's IDs in the URL and asserts 403 or 404 — never 200. This is boring, it takes a day to write, and it is the difference between a SaaS you can sell to a bank and one you cannot.

4.4 If you want isolation the database enforces

PostgreSQL Row Level Security gives you belt-and-braces on a shared schema. Two caveats the PostgreSQL manual is explicit about and most blog posts omit: superusers and roles with BYPASSRLS always bypass row security, and the table owner normally bypasses it too unless you run ALTER TABLE ... FORCE ROW LEVEL SECURITY. So your Laravel connection must use a non-owner, non-superuser role, or RLS is decoration.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
    USING (tenant_id = current_setting('app.tenant_id')::bigint);

You then issue SET LOCAL app.tenant_id = '...' at the start of each request's transaction. One practical warning, and most write-ups get this backwards: SET LOCAL is transaction-scoped, which is precisely why it is safe under PgBouncer's transaction pooling — the value dies when the transaction ends. The dangerous combination is a plain SET under transaction pooling: the server connection goes back into the pool with app.tenant_id still set on it, and whichever tenant's transaction is handed that connection next inherits the previous tenant's value. PgBouncer only runs DISCARD ALL between transactions if you explicitly enable server_reset_query_always, so do not assume it is being cleaned up for you. Test this under load, not on your laptop.

5. stancl/tenancy vs spatie/laravel-multitenancy vs Rolling Your Own

Both mainstream packages are healthy as of August 2026. Versions and constraints, taken from Packagist at the time of writing — check before you commit, these move:

stancl/tenancyspatie/laravel-multitenancyHand-rolled
Latest stable (Aug 2026)v3.10.1, released 5 Aug 2026v4.2.0, released 7 Aug 2026
PHP / LaravelPHP ^8.0; Laravel 10–13PHP ^8.2; Laravel 11–13Yours
PhilosophyAutomatic, batteries includedDeliberately unopinionatedExplicit
Multi-databaseFirst class, auto-provisioningSupported, you wire the taskYou build it
Single-databaseSupported via BelongsToTenantSupported, you write the scopeYou build it
Cache / queue / filesystem awarenessBuilt-in bootstrappersBuilt-in tasksYou build it
Best whenDB-per-tenant, want it to just workYou want to understand every moving partSimple single-DB, few models

stancl/tenancy ships five bootstrappers that solve most of section 4.2 for you: the database bootstrapper switches the default connection (note: only the default connection — explicitly named connections are untouched, which surprises people); the cache bootstrapper tags cache entries with the tenant ID; the filesystem bootstrapper suffixes storage paths and makes the Storage facade tenant-aware; the queue bootstrapper embeds the tenant ID in the job payload and re-initialises tenancy when the job runs; and the Redis bootstrapper changes the Redis prefix per tenant — that last one requires phpredis, not Predis.

Setup is genuinely short:

composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate
# then register TenancyServiceProvider in bootstrap/providers.php
# and move tenant tables into database/migrations/tenant/

spatie/laravel-multitenancy takes the opposite stance: it determines the current tenant and lets you define what happens when one becomes current, through "tasks". It will not surprise you, but it will also not do your homework. I reach for it when the client's team will maintain the code and I want them to understand every line.

Rolling your own is entirely reasonable for single-database tenancy with fewer than about fifteen tenant-owned models. A scope class, a trait, a middleware that binds currentTenant, and the CI test from 4.3. That is roughly 200 lines and no upgrade treadmill. I have shipped this more often than either package, and it is what I usually recommend inside a first Laravel + React SaaS MVP.

Version note: Laravel 12 stopped receiving bug fixes on 13 August 2026 and continues to receive security fixes only until 24 February 2027. Laravel 13 (17 March 2026) requires PHP 8.3 minimum. If you are starting a multi-tenant build now, start on 13 — retrofitting tenancy during a framework upgrade is the worst of both jobs.

6. At How Many Tenants Does Single-Database Tenancy Break?

Wrong question, and it is the question everyone asks. Single-database tenancy does not break at a tenant count. It breaks at a row count in your hottest table, and at skew.

The rough thresholds I work to, on a well-indexed MySQL 8 or PostgreSQL 16+ instance with adequate memory:

Hot table sizeWhat happensWhat to do
Under 10M rowsNothing. A composite index on (tenant_id, created_at) handles everything.Ignore the problem.
10M–100M rowsSlow aggregates, slow COUNT(*), index bloat.Composite indexes, materialised counters, read replica for reports.
100M–500M rowsMaintenance windows hurt. Adding a column locks or takes hours.Partition by tenant_id range or hash; archive cold tenants.
Over 500M rowsBackups and restores become the constraint, not queries.Shard by tenant into pods, or move whales to their own DB.

Skew matters more than the total. If your top tenant is 100× your median tenant, their table scans will evict everyone else's pages from the buffer pool and your p95 will be terrible for customers who are doing nothing wrong. Watch for that ratio in your metrics from month one.

Where database-per-tenant breaks — and this one is a hard wall

Connections. Each distinct database name is a separate connection Laravel opens, and neither PHP nor Laravel has a connection pool to amortise them — that is exactly why this bites. With PHP-FPM, every worker that touches a tenant DB holds an open connection to it for the life of that worker.

Amazon RDS sets the default MySQL max_connections to {DBInstanceClassMemory/12582880} — roughly memory-in-MB divided by 12. PostgreSQL uses LEAST({DBInstanceClassMemory/9531392}, 5000). In practice a MySQL instance on a db.t3.micro gets about 60 connections, and even a modest 8 GiB class lands around 630. Run 40 PHP-FPM workers across two app servers, each serving requests for different tenants, and you can exhaust a small instance's connection budget with fewer than a hundred active tenants. You then need RDS Proxy or PgBouncer, which is another moving part, another failure mode and another bill.

Second wall: MySQL's table_open_cache and open_files_limit. Five hundred tenants × forty tables is 20,000 tables. The MySQL manual is direct about the consequence of setting table_open_cache too high: the server runs out of file descriptors and starts refusing connections or failing queries. You will be tuning OS-level file descriptor limits, which is not what your seed round was for.

Third wall: migration fan-out. A migration that takes two seconds takes 33 minutes across 1,000 tenants if you run it serially, and you must handle the tenants that fail halfway. Every deployment becomes a distributed transaction you are managing by hand.

My honest numbers: database-per-tenant is comfortable to about 200–500 tenants on one server. Past that you are building tenant pods — groups of tenants per database server — and you have become an infrastructure company. Shared-schema tenancy on decent hardware handles tens of thousands of tenants before the row counts above become the binding constraint.

7. The Operational Bill Nobody Quotes You

When a developer quotes you for "multi-tenant with a database per tenant", ask them which of these is included. In my experience most quotes include the first item and none of the rest.

  • Provisioning. Creating the database, running migrations, seeding defaults, and handling the failure halfway through. This must be a queued job with retries and a visible status in your admin panel, not an inline HTTP request.
  • Deprovisioning. Deleting a tenant's database on cancellation, after a retention window, with a final export. Getting this wrong is a GDPR and PDPL problem, not just a housekeeping one.
  • Migration orchestration. Running migrations across N databases, in parallel batches, with per-tenant success tracking and a resume path.
  • Backups. N backup jobs, N restore tests. An untested backup is not a backup.
  • Monitoring. Per-tenant slow query visibility, per-tenant disk usage, alerts before a tenant DB fills.
  • Cross-tenant reporting. Your own business metrics — MRR, churn, feature adoption — now require an ETL into a warehouse.
  • Support tooling. Impersonation, read-only support access, and an audit log of who looked at what.

That list is 60–120 hours of engineering before you write a single product feature. It is also the reason I am careful about hosting choices on these projects — a $12/month shared plan cannot host database-per-tenant tenancy, and finding that out in month four is expensive.

8. How Do I Handle Tenant-Scoped Roles and Permissions?

This is the question that catches out teams who got the data layer right.

If you are on database-per-tenant, this is nearly free: spatie/laravel-permission runs inside each tenant database and roles are naturally scoped. Watch only for the permission cache, which must be namespaced per tenant or you will serve tenant A's permission map to tenant B.

If you are on a shared database, use the package's teams feature, and read these caveats before you migrate anything:

  • 'teams' => true must be set in config/permission.php before the initial migration. Turning it on afterwards means a manual schema migration and a data backfill. Decide on day one.
  • You set the active team with setPermissionsTeamId($tenantId) in middleware. That middleware must run before Laravel's SubstituteBindings, otherwise route model binding fails first and your user gets a 404 where a 403 was correct.
  • When you switch team context inside a single request — an admin viewing two tenants, a queued job iterating tenants — you must clear the cached relations yourself: $user->unsetRelation('roles')->unsetRelation('permissions'). Skip this and the second tenant's check silently returns the first tenant's answer.
  • Roles created with team_id => null are global. Use that for your own staff roles, not for tenant roles.
  • If you use Livewire, register the team middleware as persistent or the context is lost on component updates.

A pattern I now use by default: a separate Membership model joining users to tenants, carrying the role. One user, many tenants, one row per relationship. It makes agency accounts and "invite your accountant" flows possible without a rewrite, and it keeps the permission layer honest about the fact that a user is not owned by a tenant — a membership is.

9. Tenant Identification: Domain, Subdomain, or Path?

Three options, and the choice has security consequences.

MethodExampleProsWatch out for
Subdomainacme.yourapp.comClean, cookie isolation possible, standardWildcard DNS + wildcard TLS cert required
Custom domainportal.acme.comWhite-label, enterprise-friendlyPer-domain certificate automation; real ops work
Path prefixyourapp.com/acmeZero DNS work, simplestShared cookie scope — session fixation risk across tenants
Header / token claimAPI onlyRight answer for a pure APITenant must be validated against the token, never trusted from the header alone

The failure I see most: taking the tenant from a request header or a hidden form field on an API and trusting it. The tenant must be derived from the authenticated identity — the token's claim, the session's membership — and cross-checked. If a client can send you a tenant ID and have you believe it, you do not have multi-tenancy, you have a query parameter. This is basic API design hygiene and it is skipped constantly.

Also: with subdomain tenancy, set your session cookie domain explicitly. A cookie scoped to .yourapp.com is shared across every tenant subdomain, which is exactly the thing you were trying to avoid.

10. How Much Does Multi-Tenancy Add to a SaaS Build Cost?

These are ranges from my own quoting, not a fixed price list, and they assume a competent single-developer or small-team build with the product features quoted separately. The tenancy layer is what I am pricing here — signup, provisioning, isolation, admin panel, tests. Currency figures are rounded and stated in USD as the anchor; local figures are approximate at August 2026 rates. The SAR column is converted at the 3.75 peg — if you are quoting in AED, the same numbers run roughly 2% high, since the dirham pegs at about 3.6725.

ScopeEffortUSDEGP (approx)SAR (approx)
Single-DB tenancy, hand-rolled, up to ~15 models25–45 hrs$1,000 – $2,20050k – 110k3.8k – 8.3k
Single-DB tenancy + teams-based roles + isolation test suite50–80 hrs$2,000 – $4,000100k – 200k7.5k – 15k
DB-per-tenant with stancl/tenancy, queued provisioning, migration orchestration90–150 hrs$3,600 – $7,500180k – 375k13.5k – 28k
Add custom domains + automated TLS per tenant+20–40 hrs+$800 – $2,000+40k – 100k+3k – 7.5k
Add subscription billing, plan limits, usage metering+40–70 hrs+$1,600 – $3,500+80k – 175k+6k – 13k
Migrating an existing single-tenant app to multi-tenant80–200+ hrs$3,200 – $10,000+160k – 500k+12k – 37k+

Two honest notes on this table. First, the last row has the widest spread because it depends entirely on how many raw queries and unscoped uniques the existing codebase contains — I cannot quote it without reading the code, and neither can anyone else. Second, the recurring cost matters more than the build: database-per-tenant typically adds $80–$400 per month in infrastructure before you have a single paying customer, because you need a real database instance rather than a shared one.

If you are still forming the overall budget picture, my breakdown of what a website actually costs in 2026 covers the surrounding line items, and if payments are in scope for a Gulf launch, GCC payment gateway integration is its own conversation.

11. Starting Single-DB and Moving Later: The Actual Path

This is the section that should decide your architecture, because it makes the "wrong" first choice cheap to correct.

Design for the move on day one, at almost zero cost:

  1. Give tenants a UUID or ULID, not an auto-increment ID. When you extract a tenant into its own database, non-colliding keys save you a remapping project.
  2. Put tenant_id on every tenant-owned table, even where a parent relationship would imply it. Denormalised, yes. It means a tenant's rows can be selected with one WHERE per table instead of a join graph.
  3. Never reference another tenant's row from a foreign key. Obvious, routinely violated by shared lookup tables that later grow tenant-specific rows.
  4. Keep central data — tenants, users, subscriptions, plans — in clearly separate tables from tenant data. Draw the line in the schema before you need it.
  5. Namespace storage paths and cache keys by tenant from the first commit. Retrofitting this means migrating files, which is far worse than migrating rows.

With those five in place, extracting a tenant is: create the new database, run migrations, copy that tenant's rows table by table with WHERE tenant_id = ?, drop the column, flip a database_name field on the tenant record, verify, then delete the source rows after a retention window. For a mid-sized tenant that is a scripted job measured in minutes, plus a short maintenance window for that one customer. Nobody else notices.

What this means for your decision: the cost of choosing single-database and being wrong is a few days of extraction work per enterprise customer. The cost of choosing database-per-tenant and being wrong is eighteen months of operational drag on a team that cannot afford it. The asymmetry is not close.

12. My Build Checklist for a Laravel Multi-Tenant SaaS

  • Tenant model with ULID primary key, slug, status, and a nullable database_name reserved for future extraction.
  • Membership pivot: user ↔ tenant ↔ role. Never a tenant_id on the users table.
  • Middleware resolving the tenant from the subdomain, validating the authenticated user's membership, binding currentTenant, and running before SubstituteBindings.
  • A BelongsToTenant trait applying a fail-closed global scope and auto-filling tenant_id on create.
  • Composite unique indexes everywhere, and (tenant_id, created_at) composite indexes on every table you sort or paginate.
  • A CI test that iterates all tenant-owned models and asserts zero cross-tenant visibility, plus a route-level IDOR smoke test.
  • A static check — even a grep in CI — that fails the build on DB::table( or withoutGlobalScope outside an allow-listed namespace.
  • Cache keys, Redis prefixes, storage disks and queue jobs all tenant-aware, with tests.
  • Impersonation behind a permission, logged to an immutable audit table with the reason.
  • Per-tenant soft-delete and export endpoint, because deletion requests will come.
  • A central admin panel on a separate domain with its own auth, so a tenant subdomain compromise does not reach it.

13. The Five Mistakes I Get Called In to Fix

  1. The permissive global scope. Returns everything when no tenant is bound. Ships fine, leaks the first time a queued job or an artisan command touches the model.
  2. tenant_id on the users table. Works until one human needs access to two tenants — a consultant, an accountant, an agency, a parent company. Then it is a rewrite of your auth layer.
  3. Database-per-tenant chosen for a self-serve product. Signup takes 40 seconds, provisioning fails silently for a small fraction of signups and nobody notices, and the deploy pipeline has an unbounded step.
  4. No composite unique indexes. Discovered when the second customer cannot use the slug "general" for their first project.
  5. Cross-tenant reporting bolted onto database-per-tenant. Someone writes a loop that opens 400 connections to produce an admin dashboard, and it takes down the database at 9am on a Monday.

All five are cheaper to prevent than to fix, and all five are architecture decisions made in the first week. This is the specific reason I argue that the tenancy design conversation belongs before the first line of code, not after the MVP — a point I make at more length when comparing working with a freelancer versus an agency, since the person making this call should be the person who will still be around in month twelve.

14. Where I Land

Build single-database, shared-schema tenancy with a fail-closed global scope, composite unique indexes, a membership pivot, and the isolation test suite. Design the extraction path on day one. Move individual tenants to their own database when a contract or a data-residency rule demands it, and charge for it — enterprise isolation is a feature you sell, not a default you subsidise.

Choose the framework on the same reasoning you would use for any backend decision; my comparison of Laravel versus Node.js in 2026 covers that, and for multi-tenant B2B products with heavy relational data and back-office screens, Laravel's Eloquent scoping, migration tooling and queue system make it the shorter path.

If you are about to pay someone to build this, the most valuable hour you can spend is on the schema, not the framework. I offer a free consultation and a fixed-fee quote with a reply inside 24 hours — send me your product idea or your existing codebase through the contact page, or read more about how I approach Laravel development engagements first. I will tell you honestly which of the three architectures you need, including when the answer is the cheap one.

Frequently asked questions

Should I use one database or a database per tenant in Laravel?
Start with one database and a tenant_id column unless a signed contract, per-customer data residency, per-tenant point-in-time restore, extreme data skew, or per-tenant schema divergence forces otherwise. Single-database tenancy provisions in milliseconds, migrates once, and backs up once. You can extract individual tenants into their own database later in days, not months.
How do I guarantee tenant data isolation in Laravel?
Use a fail-closed global scope that returns zero rows when no tenant is bound, never one that returns everything. Then close the seven gaps the scope misses: raw DB queries, unique and exists validation rules, unique indexes, withoutGlobalScope calls, queued jobs, cache keys, and file storage paths. Finally, add a CI test asserting zero cross-tenant visibility on every model.
At how many tenants does single-database tenancy break?
It does not break at a tenant count, it breaks at row count and skew. Below 10 million rows in your hottest table nothing happens. Between 100 and 500 million, schema changes and maintenance windows start hurting. Shared-schema tenancy comfortably handles tens of thousands of tenants; database-per-tenant hits connection and file-descriptor walls around 200 to 500 tenants per server.
How much does multi-tenancy add to a SaaS build cost?
In my quoting, hand-rolled single-database tenancy adds roughly 25 to 45 hours, or USD 1,000 to 2,200. A full database-per-tenant setup with queued provisioning and migration orchestration runs 90 to 150 hours, or USD 3,600 to 7,500, plus USD 80 to 400 per month in extra infrastructure before your first paying customer.
How do I handle tenant-scoped roles and permissions?
On a shared database use spatie/laravel-permission with teams enabled, but set teams to true before your initial migration since retrofitting it needs a manual schema change. Call setPermissionsTeamId in middleware that runs before SubstituteBindings, and when switching tenants mid-request call unsetRelation on roles and permissions or you get the previous tenant's answer.
Which package is better, stancl/tenancy or spatie/laravel-multitenancy?
As of August 2026, stancl/tenancy v3.10.1 supports Laravel 10 to 13 and is the stronger choice for database-per-tenant, shipping bootstrappers for cache, queue, filesystem and Redis. spatie/laravel-multitenancy v4.2.0 requires PHP 8.2 and Laravel 11 to 13, and is deliberately unopinionated. Pick spatie when the client's team will maintain the code.
Can I migrate from a single database to a database per tenant later?
Yes, and it is straightforward if you plan for it. Use ULID tenant keys, put tenant_id on every tenant-owned table, never foreign-key across tenants, keep central tables separate, and namespace storage and cache by tenant from the first commit. Extraction then becomes a scripted copy plus a short maintenance window for that one customer.
Tags: LaravelSaaSMulti-TenancyDatabase DesignArchitecturePostgreSQLMySQLScalability

Ready to apply what you just read?

Free 30-minute consultation, 24-hour response, written fixed-fee quote.

Chat on WhatsApp