Contents
- 1 Building multi-tenant SaaS with Laravel: beyond “just add tenant_id”
- 2 What multi-tenancy really is (once you strip away the buzzwords)
- 3 The first big decision: your isolation strategy
- 4 Choosing based on your real situation
- 5 Laravel’s role: not magic, but solid bricks
- 6 A concrete mental model: request flow in a tenant-aware Laravel app
- 7 Onboarding a new tenant: where things get real
- 8 The subtle traps: queues, background jobs, and leaks
- 9 The database choice you don’t make – and later regret
- 10 Security: the boring part that keeps you employed
- 11 Developer experience: the part nobody talks about, but everyone feels
- 12 Local development: that one day you fight with subdomains
- 13 People, not architecture diagrams
- 14 When you’re stuck between “ship it” and “do it right”
Building multi-tenant SaaS with Laravel: beyond “just add tenant_id”
There’s a particular kind of silence you only hear when you’re about to deploy a multi-tenant SaaS for the first time.
It’s late. Coffee’s cold. You’ve got 17 tabs open on stancl/tenancy, Spatie’s docs, and three Stack Overflow threads where people are arguing about “database-per-tenant vs single-db with tenant_id.” Somewhere in that mess is a simple question:
How do I build this in a way I won’t regret in two years?
If you write PHP for a living – or want to – this isn’t just an architecture problem. It’s a career problem. Multi-tenancy is where a lot of SaaS dreams either grow up or die painfully.
Let’s talk about building multi-tenant SaaS applications with Laravel in a way that:
- doesn’t feel like juggling chainsaws,
- respects your future self,
- and still ships before your patience runs out.
I’ll assume you know Laravel reasonably well. You’ve built a few apps. Maybe even shipped something small. Now you’re thinking about that bigger idea: a B2B tool, a vertical SaaS, an internal platform you might spin out later.
And you’re wondering: How do I make it multi-tenant without turning my codebase into concrete?
What multi-tenancy really is (once you strip away the buzzwords)
At its core, multi-tenancy means:
One application. Many customers. Each customer feels alone.
Technically: multiple tenants share the same application codebase, but their data must be isolated.
Emotionally: you want every company using your app to feel like it was built just for them.
Laravel doesn’t come with a “multi-tenant mode.” It gives you building blocks:
- middleware,
- multiple database connections,
- service container bindings,
- events, queues, jobs,
- and in PHP land, some solid packages to glue it all together like
stancl/tenancyand Spatie’slaravel-multitenancy.
But before you touch a package, you have to answer one question.
The first big decision: your isolation strategy
You don’t start with code. You start with how hard you want your data walls.
In Laravel SaaS land, you’ll usually consider three main strategies:
- Single database, tenant_id column
- Multiple databases, one per tenant
- Hybrid: central + tenant databases
Let’s walk through them the way you’d explain it to a colleague across the desk.
1. Single database, tenant_id on everything
This is the “just add tenant_id” approach. All tenants share one database. Every tenant-specific table has a column like tenant_id, and you make sure every query respects it.
In Laravel terms:
- every tenant-owned model has
tenant_id, - you use global scopes to automatically filter by the current tenant,
- tenant is usually resolved by subdomain (
acme.app.test,beta.app.test) or a domain.
It looks like this:
class TenantScopedModel extends Model
{
protected static function booted()
{
static::addGlobalScope('tenant', function ($query) {
if ($tenantId = app('currentTenantId')) {
$query->where('tenant_id', $tenantId);
}
});
}
}
Strengths:
- simple to understand,
- cheaper infrastructure,
- easier reporting across tenants,
- migrations are straightforward: one schema to evolve.
Weak spots:
- one bad query without the scope, and you get cross-tenant data leaks,
- very large tables over time,
- harder to move one tenant to their own infrastructure later.
You can make this quite safe with discipline:
- default global scopes,
- authorization policies checking tenant,
- database constraints (
tenant_id+idunique, foreign keys includingtenant_id), - and tests specifically for cross-tenant leakage.
2. Separate database per tenant
Here, every tenant gets their own database. Same schema, different database name.
In Laravel:
- you define a central database (sometimes called landlord) that stores your tenants table,
- each tenant database is created when the tenant signs up,
- on each request, you figure out the tenant and switch the database connection at runtime.
Packages like stancl/tenancy or Spatie’s laravel-multitenancy are built exactly for this: they can identify the tenant (by domain, subdomain, etc.) and then adjust the DB connection before your controllers run.
This gives:
- strong isolation: data literally sits in separate databases,
- easier data export per tenant,
- simpler “enterprise upgrade” stories (“we can move you to dedicated hardware”).
Cost:
- more operational complexity,
- more migrations to think about (propagating schema changes to all tenant DBs),
- more moving parts (connection limits, monitoring multiple databases).
It’s often the right choice for B2B SaaS where data sensitivity and compliance matter, or when you anticipate very large tenants.
3. Hybrid: central plus tenant databases
This one shows up once your SaaS gets serious. You keep central, shared data in one database:
- users,
- plans,
- billing/subscriptions,
- global configs.
Then tenants’ operational data lives in separate databases.
That’s the classic landlord/tenant setup:
- central DB (landlord):
tenants,domains,users,subscriptions - tenant DBs:
projects,tasks,invoices, …
A lot of Laravel multi-tenancy patterns and docs assume this kind of architecture. It offers a sweet spot between control and isolation – and gives you a place to put logic that lives “above” individual tenants.
Choosing based on your real situation
Forget generic answers. Let’s talk scenarios:
-
Building an MVP for a tool you’re not sure will survive?
Go single database withtenant_id. Keep it lean. You can still abstract tenant logic carefully so migrating later is less painful. -
Building a line-of-business app for companies that care about audits and data residency?
Start with database-per-tenant. It costs more early, but you’ll sleep better. -
Working in a company that wants central reporting across all clients plus strong isolation for client data?
Hybrid is usually the pragmatic choice.
And remember: you’re not just choosing a technical pattern. You’re choosing what you want your operational life to feel like in two years.
Laravel’s role: not magic, but solid bricks
Laravel doesn’t try to “solve” multi-tenancy for you. It gives you:
- middleware: to detect and set tenant context for each request,
- multiple database connections: switch
DB::connection()dynamically, - service container: bind tenant-specific services (like billing configs) per request,
- queues & jobs: where you’ll need to preserve tenant context across async work.
Then packages like stancl/tenancy or Spatie’s multi-tenancy package add:
- tenant identification (by domain, subdomain, path, or custom),
- automatic connection switching,
- events for tenant lifecycle: created, updated, deleted,
- helpers to run migrations per tenant.
You can absolutely roll your own, but if you’re building a real SaaS product and not just experimenting, the time you save by using a mature package is usually worth it.
A concrete mental model: request flow in a tenant-aware Laravel app
Picture a request coming in for acme.yourapp.test.
What happens?
- HTTP hit: Nginx/Apache forwards the request to PHP-FPM and then Laravel’s
public/index.php. - Laravel bootstrap: config is loaded, service providers boot, but no tenant set yet.
- Tenant middleware kicks in:
- reads the host (
acme.yourapp.test), - finds the tenant in the central database,
- sets a
Tenantobject into the container, - optionally switches database connection, filesystem, cache prefixes.
- reads the host (
- Now your controllers, models, policies, views all run “inside” the tenant context.
If you’re using the single database approach, at step 3 you’d instead store currentTenantId somewhere (container, request) and let global scopes do their work.
That’s the whole game: correct tenant detection and consistent tenant context.
Onboarding a new tenant: where things get real
The first time you implement tenant onboarding, you realize how many moving parts a “simple” SaaS has.
Imagine you’re building a Laravel SaaS where each company gets their own workspace. A typical flow:
- someone signs up on your main marketing site (
yourapp.com), - they choose a workspace name (
acme), - you create a tenant,
- you provision their environment.
If you’re using a multi-database package like stancl/tenancy, the sequence might look like this:
$tenant = Tenant::create([
'id' => Str::uuid(),
'data' => [
'name' => 'Acme Inc.',
],
]);
$tenant->domains()->create([
'domain' => 'acme.yourapp.test',
]);
Behind that simple code there can be:
- creating a database for the tenant,
- running tenant-specific migrations,
- seeding default data (roles, permissions, default settings),
- sending a welcome email,
- maybe even provisioning a subdomain in DNS if you’re fancy.
The first time you get all these steps working together, it feels weirdly satisfying. Like you built a tiny factory that creates little universes on demand.
And then, of course, you find out your queue worker wasn’t tenant-aware, and your welcome jobs ran against the wrong tenant.
Which brings us to the part most tutorials gloss over.
The subtle traps: queues, background jobs, and leaks
HTTP requests are straightforward: a middleware sets the tenant, everything downstream sees it.
Queues and background jobs are where you can quietly break isolation if you’re not careful.
Imagine this:
- User in tenant A triggers a heavy export.
- You dispatch a job:
ExportReportJob::dispatch($reportId); - The job runs later on a worker that knows nothing about tenant A.
If you don’t encode tenant context into that job, it might:
- query the wrong data,
- accidentally access another tenant’s records,
- or fail in weird ways because the DB connection isn’t switched.
Package-based approaches often give you traits or helpers to store tenant info with the job. If you’re rolling your own, you might:
- store
tenant_idon the job, - in the job’s
handle()method, resolve and set the tenant before doing anything.
Conceptually:
class ExportReportJob implements ShouldQueue
{
public function __construct(
public int $tenantId,
public int $reportId
) {}
public function handle()
{
Tenancy::initialize($this->tenantId);
// Now all queries are tenant-safe
$report = Report::findOrFail($this->reportId);
// process export...
}
}
The point is simple: tenant context must be explicit wherever work happens.
That includes:
- HTTP requests,
- queue workers,
- scheduled commands,
- even WebSocket servers if you’re running them.
You’ll know you’ve got it right when you can run tests that create two tenants, perform operations in both, and confidently assert that data never crosses the boundary.
The database choice you don’t make – and later regret
There’s a moment in a SaaS project where someone says:
“We’ll just start with a single database, and if we need to, we’ll move to database-per-tenant later.”
Sometimes that works. Sometimes it becomes a quiet, unspoken regret that haunts every refactor.
If you go with a single database:
- design your schema as if tenants are first-class:
- composite keys that include
tenant_id, - unique constraints scoped by tenant,
- explicit foreign keys with
tenant_id.
- composite keys that include
- make your domain model tenant-aware:
- repositories or service classes that don’t assume “global” data,
- policies that always check tenant ownership.
If you go with database-per-tenant:
- keep tenant-agnostic things really tenant-agnostic:
- don’t put billing and licensing info inside tenant databases,
- don’t tie a tenant’s existence to their DB being reachable (you’ll need admin tooling).
- build admin tools early:
- “run migrations for all tenants” commands,
- health checks that tell you which tenant DBs are failing,
- scripts to move or clone tenants if needed.
Both paths are fine if they’re chosen deliberately. The regret comes from hoping you’ll “figure it out later.”
Security: the boring part that keeps you employed
Multi-tenant SaaS is, functionally, one big security exercise: can you guarantee that tenant A will never see tenant B’s data?
In Laravel, practically, that means:
- never trusting user input for tenant identification
Derive tenant from:- domain or subdomain,
- authenticated user’s tenant relation,
- or a trusted header in internal systems.
- layered protection:
- framework-level: middleware that sets and validates current tenant,
- model-level: global scopes, tenant_id presence,
- policy-level: checks that the resource belongs to the current tenant,
- database-level: foreign keys, constraints, even row-level security if you use Postgres.
- logs for cross-tenant operations
Admin features that touch multiple tenants should be auditable. When something goes wrong at scale, logs become your memory.
On a human level: you want the moment a support ticket comes in (“our user saw another company’s data”) to be a theoretical nightmare, not a realistic one.
Developer experience: the part nobody talks about, but everyone feels
Here’s something I wish more architecture discussions included:
What does it feel like to work on this codebase on a Tuesday afternoon, three months from now?
A multi-tenant Laravel app will be touched by developers who:
- join the project later,
- didn’t make the architecture decisions,
- are under pressure to “ship the feature fast.”
If your multi-tenancy model is only in your head, you’re in trouble.
You can make their life – and yours – better by:
- naming things clearly:
Tenant,CentralUser,TenantUser,LandlordDatabase,TenantDatabase– be explicit. - having a single place where tenant is resolved:
aCurrentTenantservice or similar, instead of scattering logic across controllers. - writing a simple internal doc:
“How multi-tenancy works in our app”:- where tenant & user data live,
- how queue jobs stay tenant-aware,
- how migrations are run,
- how to create a tenant locally for testing.
It’s not fancy, but it turns a confusing system into a legible one. And “legible” is extremely underrated in architecture.
For teams hiring Laravel and PHP developers – the kind of teams that might be on a platform like Find PHP – this legibility is the difference between onboarding in days vs weeks.
Local development: that one day you fight with subdomains
You haven’t really done Laravel multi-tenancy until you’ve spent an afternoon yelling at /etc/hosts.
For multi-tenant SaaS, your local dev setup often needs:
- wildcard subdomains (
*.app.test), - SSL if you’re testing more realistic setups,
- multiple databases for simulating multiple tenants.
Laravel Valet makes this almost fun with *.test domains. Docker/Sail setups can too, once you’ve wired them up.
Small tips that save time:
- have a “seed tenants” command:
- create a few demo tenants:
acme.app.test,beta.app.test, - seed realistic test data per tenant.
- create a few demo tenants:
- keep a minimal tenant switcher in your admin UI:
- “impersonate tenant” (for admins),
- jump between tenants while debugging.
It feels like polish, but when you’re hunting a tenant-specific bug at 1 AM, you’ll be grateful you invested in this.
People, not architecture diagrams
At some point, you’ll be staring at a schema migration or queue failure and forget why you’re doing this.
Multi-tenant SaaS is not about clever tenancy strategies. It’s about letting many different teams, companies, and people live inside the same product without stepping on each other.
There’s a support engineer somewhere who will have a much easier day because:
- you added a “tenant id” column to your logs,
- you built a simple tenant switcher,
- you wrote tests that saved them from a data leak incident.
There’s a future developer who will quietly thank you for:
- choosing a tenancy package instead of inventing half-broken plumbing,
- documenting why you went single-db or multi-db,
- leaving comments where things are tenant-sensitive.
And maybe, there’s you – a few years from now – who will still enjoy working in this codebase instead of treating it like a haunted house.
When you’re stuck between “ship it” and “do it right”
If you’re standing at the whiteboard, or the notebook, or just staring at PhpStorm thinking:
“How deep should I go on multi-tenancy right now?”
Try this:
- be honest about the stage you’re in:
- prototype, early customers, or growing product,
- choose the simplest multi-tenancy strategy that:
- doesn’t block your next 6–12 months,
- doesn’t make basic security impossible,
- write down that decision somewhere:
- “We chose single-DB with
tenant_idbecause X.
If we outgrow it, we’ll Y.”
- “We chose single-DB with
Code is important. But clarity with yourself is what keeps the project alive.
Whether you’re here as someone looking for a PHP job, trying to hire a Laravel developer, or just quietly building your own SaaS in the evenings after your day job – multi-tenancy is one of those topics that forces you to think like both an engineer and a caretaker.
You’re not just writing features.
You’re building a place where a lot of people will eventually work, click, rely, and complain.
And if you do it with a bit of care now, some late night in the future you’ll open your own app, switch between tenants, and feel that small, quiet satisfaction of something that hasn’t fallen apart.