In a multi-tenant SaaS, one bug matters more than all the others: a customer seeing another customer’s data. Slow pages get complaints. A data leak between tenants loses accounts, and sometimes the company.
When I designed the multi-tenant architecture for ISPKenya, 10+ internet providers were going to share one codebase and one infrastructure, each managing its own customers and devices. Isolation wasn’t a feature to add later. It was the first decision. This post is the approach I use for that decision in Laravel, and the habits that keep it safe as the product grows.
Step one: choose a tenancy model on purpose
There are three common ways to separate tenants. Each one is a trade-off, not a ranking.
Shared database, tenant column. Every tenant-owned table gets a tenant_id, and every query is filtered by it. One database to migrate, back up and monitor, and the cheapest to host. The risk is entirely in the application: forget the filter once and data leaks.
Shared server, database per tenant. Each tenant gets its own database; the application switches connection per request. Isolation is much stronger and per-tenant backups or exports are easy. The cost is operational: every migration runs once per tenant, and reporting across tenants gets harder.
Fully separate deployments. Each tenant gets its own stack. Maximum isolation, maximum cost, and a deploy pipeline you need to automate from day one.
For most early-stage products, I recommend the shared database with a tenant column, enforced so strictly that forgetting the filter is hard. Move to database-per-tenant when a customer needs it for compliance or their data volume dwarfs everyone else’s. The rest of this post assumes the tenant column model, because it is the one that needs the most discipline.
Make the filter impossible to forget
The worst way to implement tenancy is to write ->where('tenant_id', ...) in every query by hand. Sooner or later someone won’t. Laravel’s global scopes let you apply the filter automatically to every query on a model.
// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
if ($tenantId = app(CurrentTenant::class)->id()) {
$query->where($query->getModel()->qualifyColumn('tenant_id'), $tenantId);
}
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(CurrentTenant::class)->id();
});
}
}
Every tenant-owned model uses the trait. Reads are filtered, and new records are stamped with the current tenant automatically. The CurrentTenant service is resolved once per request, from the subdomain, a header or the authenticated user, and the rest of the application never decides the tenant on its own.
Two details that matter:
- Qualify the column. Without
qualifyColumn, a join between two tenant tables produces an ambiguoustenant_idand either errors or, worse, filters the wrong table. - Fail closed. If there is no current tenant on a request that should have one, throw an exception instead of quietly running an unscoped query. A clear error is far cheaper than a silent leak.
Policies are the second wall
Global scopes protect lists and queries. Route model binding, however, is where many leaks actually happen: /devices/482 loads device 482, and if anything bypasses the scope, the user sees a record they shouldn’t.
So tenancy gets a second check in authorization:
public function view(User $user, Device $device): bool
{
return $device->tenant_id === $user->tenant_id
&& $user->can('devices.view');
}
Belt and braces: the scope keeps foreign records out of queries, and the policy refuses them even if one slips through. Roles sit on top of that. On ISPKenya, access was split across 3 roles, so the question was always “is this the right tenant and the right role?”
The places scopes don’t reach
Global scopes cover Eloquent queries. Several other paths don’t go through them, and each needs its own rule.
Queued jobs. A job runs later, in a worker, with no request and no current tenant. Pass the tenant ID into the job explicitly and set it at the start of handle(). Never let a job inherit tenant state from whatever ran before it in the same worker.
Cache keys. Cache::remember('dashboard-stats', ...) is a leak waiting to happen. Prefix every tenant-specific key with the tenant ID, or use cache tags per tenant.
Raw queries and reports. DB::table() and raw SQL skip model scopes entirely. Keep them rare, keep them in one place, and review them with tenancy in mind.
Files. Store uploads under a tenant-specific path and check ownership before generating download URLs.
Admin tools. Your own staff need to work across tenants. Make that an explicit, logged mode (withoutGlobalScope('tenant') in a few well-named places), not the default.
Log who did what
When several organisations act on shared infrastructure, sooner or later someone asks “who changed this?”. An audit log answers it: the tenant, the user, the action, the record, and when. On a platform where actions touch real customer hardware, as ISPKenya’s TR-069 device management does, that log is as important as the feature itself.
Prove it with tests
Isolation should be tested like any other requirement, and the test is simple to write: create two tenants, act as one, and try to reach the other’s data through every path.
it('never shows another tenant’s devices', function () {
[$a, $b] = Tenant::factory()->count(2)->create();
$foreign = Device::factory()->for($b)->create();
actingAsTenantUser($a)
->get("/devices/{$foreign->id}")
->assertNotFound();
actingAsTenantUser($a)
->get('/devices')
->assertDontSee($foreign->serial);
});
Run that pattern for every tenant-owned resource, including exports, API endpoints and background jobs. It is boring to write, and it is the test that lets you ship quickly without fear.
The short version
- Choose the tenancy model deliberately; a shared database with a tenant column suits most early products.
- Enforce the filter with a global scope and stamp new records automatically.
- Fail closed when the tenant is missing.
- Check tenancy again in policies, because route model binding is where leaks hide.
- Handle jobs, caches, raw queries, files and admin tools explicitly.
- Keep an audit log and test isolation for every resource.
Get these right at the start and multi-tenancy stops being a risk you worry about and becomes a property of the system. If you want to see how this played out on a real platform, the ISPKenya case study covers the architecture, the TR-069 integration and the results.