I've been building SaaS applications with PHP and Laravel for well over a decade. In that time, I've shipped multi-tenant products ranging from small team tools with a few hundred users to enterprise platforms serving tens of thousands of active tenants simultaneously. And if there's one architectural decision that determines more about your product's future than almost anything else, it's how you implement multi-tenancy from day one.
Get it wrong and you'll spend the next two years refactoring. Get it right and your Laravel SaaS app will scale gracefully, keep customer data safely isolated, and let you sleep at night without worrying about cross-tenant data leaks. This article covers the patterns I've used in real production environments — not theoretical code from a tutorial, but battle-tested approaches that hold up under real load.
"The architecture decisions you make before your first paying customer will outlast your first three rounds of funding. Multi-tenancy is one of the few places where you genuinely cannot iterate your way out of a bad early choice."
What Is Multi-Tenancy and Why Does It Matter?
Multi-tenancy is the ability of a single application instance to serve multiple customers — tenants — while keeping their data logically or physically separate. It's the foundation of virtually every SaaS product built with PHP or Laravel today.
Unlike single-tenant apps where you spin up a fresh instance per customer, a multi-tenant architecture runs one codebase and serves everyone. The efficiency gains are enormous: lower infrastructure costs, simpler deployments, and a single place to push updates. But the tradeoff is that you carry the full responsibility of keeping tenant data separated. A single missed where tenant_id = ? clause can become a catastrophic data breach.
The good news: Laravel's architecture makes multi-tenancy more tractable than any other PHP framework I've worked with. Its service container, middleware pipeline, and Eloquent global scopes are perfectly suited for the patterns we'll cover below.
The Three Multi-Tenancy Models in Laravel
Before writing a line of code, you need to choose your isolation model. In Laravel SaaS development, there are three primary approaches:
- Database-per-tenant — each tenant gets their own dedicated database
- Schema-per-tenant — each tenant gets their own schema within the same database server (common with PostgreSQL)
- Shared database, shared schema — all tenants share the same tables, distinguished by a
tenant_idcolumn
Each model represents a different point on the isolation-versus-efficiency spectrum. Let me walk through what each one means in practice.
Database-Per-Tenant: Maximum Isolation
The database-per-tenant model is the most isolated approach. Every time a new customer signs up, your Laravel app provisions a fresh database, runs migrations against it, and associates it with that tenant's record in a central "landlord" database.
When this model makes sense
Choose database-per-tenant when your customers are enterprises with strict data residency requirements, when you operate in regulated industries like healthcare or finance, or when individual tenants have genuinely huge data volumes that would cause performance problems in a shared schema.
The clear advantage is physical isolation. Even if a query somehow bypassed your application-level tenant checks — which shouldn't happen, but you're planning for the worst — there's a hard boundary at the database level. You can also restore a single tenant from backup without affecting anyone else, which compliance teams love.
The tradeoff is operational complexity. Running database migrations across hundreds of tenant databases requires careful orchestration. I've used a queued job approach in Laravel where each tenant database migration is dispatched as an independent job, allowing you to track progress and handle failures per tenant without bringing down the whole migration run.
// Dispatch migrations across all tenant databases
Tenant::each(function ($tenant) {
dispatch(new RunTenantMigrations($tenant));
});
// Inside RunTenantMigrations job
public function handle(): void
{
DB::purge('tenant');
config(['database.connections.tenant.database' => $this->tenant->database]);
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => '/database/migrations/tenant',
'--force' => true,
]);
}
Keep a separate /database/migrations/tenant directory for tenant-specific migrations and /database/migrations for your central landlord tables. This separation prevents accidental mixing of scopes and makes your migration history far easier to reason about.
Shared Schema: Efficient and Scalable
The shared schema model — sometimes called single-database multi-tenancy — is where all tenants share the same tables and are distinguished by a tenant_id foreign key on every table. This is the model I reach for most often when building PHP SaaS applications at early-to-mid scale, because it has the lowest operational overhead while still being very secure when implemented correctly.
The secret weapon here is Eloquent Global Scopes. Rather than manually appending ->where('tenant_id', $currentTenant->id) to every single query — which is error-prone and will eventually bite you — you define a global scope that automatically injects the tenant filter into every query for every tenant-scoped model.
// app/Models/Scopes/TenantScope.php
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenantId = app('currentTenant')?->id) {
$builder->where('tenant_id', $tenantId);
}
}
}
// Apply to any tenant-scoped model via trait
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
$model->tenant_id ??= app('currentTenant')->id;
});
}
}
With this trait applied to your Eloquent models, every query is automatically filtered. The tenant context is set once — in middleware — and flows through the entire request lifecycle. It's clean, it's predictable, and critically, it's hard to accidentally bypass.
Building the Tenant Middleware
The tenant middleware is the entry point of your entire multi-tenancy system. Its job is simple: identify which tenant this request belongs to, resolve that tenant from the database, bind it into the service container, and set up the database connection if you're using database-per-tenant isolation.
// app/Http/Middleware/IdentifyTenant.php
class IdentifyTenant
{
public function handle(Request $request, Closure $next): Response
{
$host = $request->getHost();
$subdomain = explode('.', $host)[0];
$tenant = Tenant::where('subdomain', $subdomain)
->where('is_active', true)
->firstOrFail();
// Bind into container for global access
app()->instance('currentTenant', $tenant);
// Set tenant on the authenticated user if applicable
if ($user = $request->user()) {
abort_if($user->tenant_id !== $tenant->id, 403);
}
return $next($request);
}
}
Register this middleware in your HTTP kernel's route middleware and apply it to every route group that handles tenant traffic. Don't apply it to your marketing site, your public sign-up flow, or your billing webhooks — those live outside the tenant context.
Subdomain Routing in Laravel
One of the most polished experiences you can give your SaaS customers is a dedicated subdomain — acme.yourapp.com, globex.yourapp.com. It feels professional, makes deep-linking natural, and gives you a reliable signal to identify the tenant on every request.
Laravel makes subdomain routing remarkably clean:
// routes/web.php
Route::domain('{tenant}.yourapp.com')
->middleware(['web', 'tenant'])
->group(base_path('routes/tenant.php'));
// Or dynamically from config
Route::domain('{tenant}.' . config('app.base_domain'))
->middleware(['web', IdentifyTenant::class])
->group(base_path('routes/tenant.php'));
On the infrastructure side, you'll need a wildcard DNS record pointing *.yourapp.com to your server, and a wildcard SSL certificate from Let's Encrypt or your CDN provider. Cloudflare makes the certificate part trivially easy and is what I use on most Laravel SaaS deployments.
Common Pitfalls and How to Avoid Them
After shipping multiple multi-tenant products in PHP and Laravel, I've made — and fixed — most of the mistakes you can make. Here are the ones that hurt the most:
- Caching without tenant isolation. If you cache a query result under a generic key like
users:list, every tenant will see the same cached data. Always namespace cache keys with the tenant ID:"tenant:{$tenantId}:users:list". - Queue jobs that lose tenant context. When you dispatch a queued job, the tenant context from the middleware is gone. Store the tenant ID on the job and re-resolve it in the
handle()method. - File storage mixing. Every uploaded file should live under a tenant-scoped path in S3 or your local disk:
tenants/{tenant_id}/uploads/filename.pdf. Never let files from different tenants share a flat directory. - Forgetting to scope relationships. It's not enough to scope the parent model — eager-loaded relationships need to be scoped too, or a crafty URL manipulation can expose cross-tenant data through a nested resource endpoint.
- Global Eloquent observers firing across tenants. If you use model observers for things like audit logging, make sure the observer respects the current tenant context and writes to the correct tenant's audit trail.
Which Model Should You Choose?
My honest recommendation after two decades of building PHP and Laravel SaaS applications: start with shared schema, plan for database-per-tenant at enterprise tier.
For most early-stage SaaS products, the operational simplicity of shared schema far outweighs its limitations. You can run migrations with a single php artisan migrate, you don't need to manage connection pools across hundreds of databases, and your infrastructure costs stay lean while you find product-market fit.
When you land your first enterprise customer who asks about data residency, you can introduce a database-per-tenant option as a premium tier feature — even in the same codebase. The key is making the tenant context and database connection abstract enough from day one that swapping the connection per tenant is a configuration change, not a rewrite.
The multi-tenancy architecture you choose shapes every other decision you'll make about your SaaS product — from how you handle backups to how you implement your billing system to how you debug a production issue at 2 AM. Take the time to get it right, and it will pay dividends for the entire life of your product.
If you're working on a Laravel SaaS application and want to talk through the architecture choices specific to your product, feel free to reach out. I've been through this enough times to help you avoid the most expensive mistakes before you make them.
