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:
- Shared database, shared schema. One database, one set of tables, every tenant-owned row carries a
tenant_idforeign key. Isolation is enforced in application code. - Shared database, separate schema. One PostgreSQL database, one schema per tenant. Isolation is enforced by the
search_path. - 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.
| Dimension | Shared DB, shared schema | Schema per tenant (PG) | Database per tenant |
|---|---|---|---|
| Isolation enforced by | Application code (global scopes) | search_path | Connection / credentials |
| Blast radius of one bug | Some rows | One tenant's tables | One whole tenant DB |
| Tenant provisioning time | Milliseconds | 1–10 seconds | 5–60 seconds (often queued) |
| Migration cost at 1,000 tenants | One run | 1,000 runs | 1,000 runs |
| Per-tenant point-in-time restore | Painful, custom tooling | Straightforward | Trivial |
| Cross-tenant reporting | Trivial SQL | Hard (UNION over schemas) | Hard (needs a warehouse) |
| Connection pool pressure | Low | Low | High — see section 6 |
| Data residency per tenant | Not possible | Not possible | Yes |
| DevOps hours per month (my estimate) | 2–5 | 6–12 | 10–30 |
| Good default for | B2B SaaS, self-serve, < 5k tenants | Mid-market PG shops | Enterprise, 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.
| Question | Points to shared DB | Points to DB-per-tenant |
|---|---|---|
| How do customers sign up? | Self-serve, credit card, instant | Sales-led, contract, onboarding call |
| How many tenants in 24 months? | Hundreds to thousands | Dozens to low hundreds |
| Average revenue per tenant? | Under $200 / SAR 750 per month | Over $2,000 / SAR 7,500 per month |
| Does any signed contract mention data separation? | No | Yes |
| Do you need cross-tenant analytics in-product? | Yes (benchmarks, admin dashboards) | No |
| Do you have a DevOps person or budget for one? | No | Yes |
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')andDB::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, reviewedReportingnamespace. - 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 writeRule::unique('posts', 'slug')->where('tenant_id', tenant('id'))every time, and the same forexists. - 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 writesInvoice::withoutGlobalScopes()->findOrFail($id)in a controller "just to debug something", you have an IDOR. Grep forwithoutGlobalScopein code review, every time. - Queued jobs. A job serialised with
SerializesModelsstores 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 inhandle(), 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/tenancy | spatie/laravel-multitenancy | Hand-rolled | |
|---|---|---|---|
| Latest stable (Aug 2026) | v3.10.1, released 5 Aug 2026 | v4.2.0, released 7 Aug 2026 | — |
| PHP / Laravel | PHP ^8.0; Laravel 10–13 | PHP ^8.2; Laravel 11–13 | Yours |
| Philosophy | Automatic, batteries included | Deliberately unopinionated | Explicit |
| Multi-database | First class, auto-provisioning | Supported, you wire the task | You build it |
| Single-database | Supported via BelongsToTenant | Supported, you write the scope | You build it |
| Cache / queue / filesystem awareness | Built-in bootstrappers | Built-in tasks | You build it |
| Best when | DB-per-tenant, want it to just work | You want to understand every moving part | Simple 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 size | What happens | What to do |
|---|---|---|
| Under 10M rows | Nothing. A composite index on (tenant_id, created_at) handles everything. | Ignore the problem. |
| 10M–100M rows | Slow aggregates, slow COUNT(*), index bloat. | Composite indexes, materialised counters, read replica for reports. |
| 100M–500M rows | Maintenance windows hurt. Adding a column locks or takes hours. | Partition by tenant_id range or hash; archive cold tenants. |
| Over 500M rows | Backups 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' => truemust be set inconfig/permission.phpbefore 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'sSubstituteBindings, 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 => nullare 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.
| Method | Example | Pros | Watch out for |
|---|---|---|---|
| Subdomain | acme.yourapp.com | Clean, cookie isolation possible, standard | Wildcard DNS + wildcard TLS cert required |
| Custom domain | portal.acme.com | White-label, enterprise-friendly | Per-domain certificate automation; real ops work |
| Path prefix | yourapp.com/acme | Zero DNS work, simplest | Shared cookie scope — session fixation risk across tenants |
| Header / token claim | API only | Right answer for a pure API | Tenant 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.
| Scope | Effort | USD | EGP (approx) | SAR (approx) |
|---|---|---|---|---|
| Single-DB tenancy, hand-rolled, up to ~15 models | 25–45 hrs | $1,000 – $2,200 | 50k – 110k | 3.8k – 8.3k |
| Single-DB tenancy + teams-based roles + isolation test suite | 50–80 hrs | $2,000 – $4,000 | 100k – 200k | 7.5k – 15k |
| DB-per-tenant with stancl/tenancy, queued provisioning, migration orchestration | 90–150 hrs | $3,600 – $7,500 | 180k – 375k | 13.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-tenant | 80–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:
- 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.
- Put
tenant_idon every tenant-owned table, even where a parent relationship would imply it. Denormalised, yes. It means a tenant's rows can be selected with oneWHEREper table instead of a join graph. - Never reference another tenant's row from a foreign key. Obvious, routinely violated by shared lookup tables that later grow tenant-specific rows.
- Keep central data — tenants, users, subscriptions, plans — in clearly separate tables from tenant data. Draw the line in the schema before you need it.
- 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 nullabledatabase_namereserved for future extraction. - Membership pivot: user ↔ tenant ↔ role. Never a
tenant_idon the users table. - Middleware resolving the tenant from the subdomain, validating the authenticated user's membership, binding
currentTenant, and running beforeSubstituteBindings. - A
BelongsToTenanttrait applying a fail-closed global scope and auto-fillingtenant_idon 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(orwithoutGlobalScopeoutside 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
- 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.
- 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.
- 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.
- No composite unique indexes. Discovered when the second customer cannot use the slug "general" for their first project.
- 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.