Multi-tenant SaaS is the most common shape of Laravel work we get asked about, and the most common place we find data leaks during an audit. "Tenant" might mean a company, a workspace, a clinic or a franchise location — the mechanics are the same: many customers share one application, and no customer may ever see another customer's rows.
This tutorial builds single-database tenancy in a Laravel 13 application: tenant resolution, automatic scoping, safe relationships, queue jobs that remember who they belong to, cache and storage isolation, and the tests that stop a regression from becoming an incident.
Pick a tenancy model first
There are three realistic options, and the choice is architectural — changing it later is a migration project.
| Model | Isolation | Cost of a migration | Good fit |
|---|---|---|---|
Single database, tenant_id column | Application-enforced | One migration for all tenants | Most SaaS: hundreds to millions of tenants |
| Database (or schema) per tenant | Strong, connection-level | Runs per tenant | Tens to low hundreds of tenants, compliance requirements |
| Cluster per tenant | Total | Per deployment | Enterprise/on-prem contracts |
Single-database tenancy is what this tutorial covers because it is what most products need. It is cheap to operate and easy to report across, and its weakness is exactly the thing we are going to engineer away: isolation depends on your code being right on every query.
If you have a hard regulatory requirement for physical separation, use database-per-tenant and accept the operational overhead (migrating 400 databases at deploy time is a queue design problem of its own).
The schema
Every tenant-owned table gets a tenant_id foreign key. Not "most tables" — every one, including pivots and log tables.
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable()->unique();
$table->timestamps();
});
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->timestamps();
$table->unique(['tenant_id', 'name']);
$table->index(['tenant_id', 'created_at']);
});
Two details that matter more than they look:
- Composite uniqueness. A bare
unique()onnamemakes the second tenant unable to create a project called "Website". Scope every unique constraint withtenant_id. - Composite indexes lead with
tenant_id. Every tenant query filters on it, so['tenant_id', 'created_at']serves both the filter and the sort. A standalone index oncreated_atwill mostly go unused.
Resolve the tenant once
Resolution happens in exactly one place. Everything downstream reads the resolved tenant and never re-derives it from the request.
<?php
namespace App\Tenancy;
use App\Models\Tenant;
class TenantContext
{
protected ?Tenant $tenant = null;
public function set(Tenant $tenant): void
{
$this->tenant = $tenant;
}
public function get(): ?Tenant
{
return $this->tenant;
}
public function id(): ?int
{
return $this->tenant?->id;
}
public function getOrFail(): Tenant
{
return $this->tenant ?? throw new TenantNotResolvedException;
}
public function forget(): void
{
$this->tenant = null;
}
}
Register it as a singleton in a service provider, then resolve in middleware. Subdomain routing:
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Tenancy\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ResolveTenant
{
public function __construct(protected TenantContext $context) {}
public function handle(Request $request, Closure $next)
{
$slug = $request->route('tenant')
?? explode('.', $request->getHost())[0];
$tenant = Tenant::where('slug', $slug)->first()
?? throw new NotFoundHttpException;
abort_unless($request->user()?->belongsToTenant($tenant), 403);
$this->context->set($tenant);
return $next($request);
}
}
The abort_unless line is the one people leave out. Resolving a tenant from the URL without checking that the authenticated user belongs to it turns your subdomain into an enumeration tool.
Register the middleware in bootstrap/app.php and apply it to a route group:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias(['tenant' => \App\Http\Middleware\ResolveTenant::class]);
})
Route::domain('{tenant}.'.config('app.domain'))
->middleware(['web', 'auth', 'tenant'])
->group(base_path('routes/tenant.php'));
Scope automatically, not manually
Hand-written where('tenant_id', ...) clauses are the failure mode. One forgotten clause in one query is a cross-tenant leak. Use a global scope plus automatic attribute filling, applied through a trait:
<?php
namespace App\Tenancy;
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 ($tenantId = app(TenantContext::class)->id()) {
$builder->where($model->qualifyColumn('tenant_id'), $tenantId);
}
}
}
<?php
namespace App\Tenancy;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
$model->tenant_id ??= app(TenantContext::class)->getOrFail()->id;
});
}
public function tenant()
{
return $this->belongsTo(\App\Models\Tenant::class);
}
}
Add use BelongsToTenant; to every tenant-owned model. Note the ??=: an explicitly set tenant_id is preserved, which keeps seeders and admin tooling usable.
Fail closed, not open
TenantScope above silently applies no filter when no tenant is resolved. In a web request that state is a bug, and a bug that returns every tenant's data. Decide the policy deliberately:
- In HTTP requests behind the
tenantmiddleware, an unresolved tenant should throw. - In console commands, scheduled work and admin panels, running unscoped is legitimate but must be explicit.
A workable compromise is to throw unless something has opted out:
public function apply(Builder $builder, Model $model): void
{
$context = app(TenantContext::class);
if ($id = $context->id()) {
$builder->where($model->qualifyColumn('tenant_id'), $id);
return;
}
if (! app()->runningInConsole() && ! $context->isCentralContext()) {
throw new TenantNotResolvedException(static::class);
}
}
And give yourself one loud, greppable escape hatch for cross-tenant work:
Project::withoutGlobalScope(TenantScope::class)->count();
Every appearance of that call in a diff should get a second reviewer.
The gaps global scopes do not close
Global scopes cover Model::query(). They do not cover:
1. Route model binding via relationships. Project::find($id) is scoped; DB::table('projects')->find($id) is not. Never use the query builder directly on tenant tables.
2. Relationship traversal. $user->projects() is scoped because the related model has the scope. But belongsToMany pivot inserts are not: $project->users()->attach($userId) will happily attach a user from another tenant, because $userId came from the request. Validate ownership with a scoped exists rule:
$request->validate([
'user_id' => ['required', Rule::exists('users', 'id')
->where('tenant_id', app(TenantContext::class)->id())],
]);
3. Mass operations and raw SQL. DB::statement, upsert, and anything you wrote for performance bypasses Eloquent events and scopes. Add the tenant_id yourself and add a test.
4. Unique validation rules. Rule::unique('projects', 'name') checks globally. Scope it: ->where('tenant_id', $tenantId).
Queues: the second most common leak
A job serialises the model, not the tenant context. When the worker picks it up, TenantContext is empty — and if your scope fails open, the job processes every tenant's rows. Laravel's Context facade is the clean fix, because context is propagated into queued jobs automatically:
// In ResolveTenant middleware, after set():
Context::add('tenant_id', $tenant->id);
Then rehydrate on the worker side with a job middleware, or in a queue event listener registered in a service provider:
use Illuminate\Queue\Events\JobProcessing;
use Illuminate\Queue\Events\JobProcessed;
Queue::before(function (JobProcessing $event) {
if ($id = Context::get('tenant_id')) {
app(TenantContext::class)->set(Tenant::withoutGlobalScopes()->find($id));
}
});
Queue::after(fn (JobProcessed $event) => app(TenantContext::class)->forget());
The forget() matters: workers are long-lived processes. Leaking context between jobs is the same class of bug as leaking it between requests, which is also why tenancy and Octane need care — with a persistent application container, anything you cached in a singleton survives into the next request unless you clear it.
Cache, storage and mail
- Cache keys must be namespaced:
Cache::tags(["tenant:{$id}"])on a tagged store, or atenantKey()helper that prefixes manually on Redis without tags. A cacheddashboard.statskey served to the wrong tenant is a leak with no database query to blame. - Filesystem. Prefix paths (
tenants/{id}/...) or register a per-tenant disk at resolution time. Never accept a client-supplied path. - Mail and notifications. Per-tenant branding, from-address and reply-to belong in the tenant record, not in
config/mail.php. - Scheduled tasks. A schedule closure has no tenant. Loop tenants explicitly and dispatch one job per tenant, so a slow or failing tenant cannot stall the others.
Test isolation, do not hope for it
Two tests catch most of what matters. First, a data-visibility test per tenant-owned model:
it('never returns another tenant\'s projects', function () {
$a = Tenant::factory()->has(Project::factory()->count(3))->create();
$b = Tenant::factory()->has(Project::factory()->count(2))->create();
app(TenantContext::class)->set($a);
expect(Project::count())->toBe(3);
expect(Project::pluck('tenant_id')->unique()->all())->toBe([$a->id]);
});
Second, an architecture test that no one can add a tenant table without the trait:
it('applies tenancy to every tenant-owned model', function () {
$models = collect(File::files(app_path('Models')))
->map(fn ($f) => 'App\\Models\\'.$f->getFilenameWithoutExtension())
->reject(fn ($class) => in_array($class, [Tenant::class, User::class]));
foreach ($models as $class) {
$table = (new $class)->getTable();
if (Schema::hasColumn($table, 'tenant_id')) {
expect(class_uses_recursive($class))
->toContain(BelongsToTenant::class, "{$class} is missing BelongsToTenant");
}
}
});
Add a route-level test that hitting tenant B's subdomain as a tenant A user returns 403, and you have covered the three ways this breaks in production: the query, the model, and the URL.
When to reach for a package
stancl/tenancy and spatie/laravel-multitenancy both solve this well and are worth using when you need database-per-tenant, per-tenant domains and cache/queue switching out of the box. Build it yourself, as above, when you are single-database and want the scoping rules to be small enough to read in one sitting. What you should not do is half of each — a package for resolution plus hand-rolled scopes is how contradictory assumptions get in.
Checklist before launch
- Every tenant table has
tenant_id, non-nullable, foreign-keyed. - Every unique constraint and
uniquevalidation rule is scoped by tenant. - Composite indexes lead with
tenant_id. - The global scope fails closed in HTTP requests.
- Queue jobs restore and then clear tenant context.
- Cache keys, storage paths and broadcast channels are namespaced per tenant.
withoutGlobalScopeappears only in reviewed, deliberate places.- Isolation tests exist per model, plus one architecture test.
We design and audit multi-tenant Laravel applications, including tenancy retrofits on existing single-tenant codebases. If that sounds like your project, take a look at our enterprise Laravel app development and consulting services, or just get in touch.