The Core Problem: Implicit vs. Explicit Tenancy
Most multi-tenant Laravel apps start with a tenant_id column on every table and a where('tenant_id', $current) sprinkled throughout controllers. That works until a developer forgets one clause and a customer sees another customer's data. The fix is to make tenancy structural, not optional.
This article focuses on the single-database, shared-schema model — the most common starting point for SaaS — and shows how to make tenant isolation automatic and testable.
Step 1: Resolve the Tenant Early
Create a TenantResolver service that extracts the tenant from the request (subdomain, custom header, or JWT claim) and binds it into the container as a singleton for the request lifecycle.
// app/Tenancy/TenantResolver.php
class TenantResolver
{
public function fromRequest(Request $request): Tenant
{
$host = $request->getHost(); // e.g. acme.app.test
$subdomain = explode('.', $host)[0];
return Tenant::where('slug', $subdomain)
->firstOrFail();
}
}
Bind it in a middleware that runs before any route logic:
// app/Http/Middleware/IdentifyTenant.php
public function handle(Request $request, Closure $next): Response
{
$tenant = app(TenantResolver::class)->fromRequest($request);
app()->instance(Tenant::class, $tenant);
app()->instance('current.tenant', $tenant);
return $next($request);
}
Register it in bootstrap/app.php (Laravel 11+) or Kernel.php before your route middleware group.
Step 2: Enforce Isolation with a Global Scope
A GlobalScope applied to every tenant-owned model is the safest mechanism. It runs on every SELECT, UPDATE, and DELETE automatically.
// app/Tenancy/TenantScope.php
use Illuminate\Database\Eloquent\{Builder, Model, Scope};
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenant = app('current.tenant');
$builder->where(
$model->getTable() . '.tenant_id',
$tenant->id
);
}
}
Create a BelongsToTenant trait to keep models clean:
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function (Model $model) {
if (empty($model->tenant_id)) {
$model->tenant_id = app('current.tenant')->id;
}
});
}
}
Now any model using this trait is automatically scoped:
class Project extends Model
{
use BelongsToTenant;
}
// This query is automatically WHERE tenant_id = ? under the hood
$projects = Project::where('status', 'active')->get();
Step 3: The Pitfalls That Leak Data
Queued Jobs
The container binding is request-scoped. A queued job runs in a fresh process with no HTTP request. Always serialize the tenant ID on the job and re-bind it in handle():
class ProcessInvoice implements ShouldQueue
{
public function __construct(
public readonly int $tenantId,
public readonly int $invoiceId,
) {}
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
app()->instance('current.tenant', $tenant);
$invoice = Invoice::findOrFail($this->invoiceId); // scoped
}
}
withoutGlobalScope in Tests
Test helpers that call withoutGlobalScopes() to "simplify" setup silently disable your entire isolation layer. Instead, bind a test tenant in setUp():
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
app()->instance('current.tenant', $this->tenant);
});
Raw Queries and DB Facade
Global scopes do not apply to DB::select() or DB::statement(). Audit every raw query and pass tenant_id explicitly, or wrap them in a TenantAwareQuery helper that injects the clause.
Step 4: Verify Isolation with an Architecture Test
// tests/Architecture/TenancyTest.php
arch('tenant-owned models use BelongsToTenant')
->expect('App\\Models')
->toUseTrait('App\\Tenancy\\BelongsToTenant')
->ignoring(['App\\Models\\Tenant', 'App\\Models\\User']);
This Pest architecture test fails CI the moment a developer adds a new model without the trait.
Takeaways
- Resolve the tenant once in middleware and bind it as a container singleton — never pass it through method arguments.
- Use a
GlobalScope+ trait combo so isolation is opt-out, not opt-in. - Queued jobs must re-bind the tenant; the HTTP request context does not carry over.
- Raw
DB::calls bypass global scopes entirely — treat them as a security boundary. - An architecture test that enforces trait usage catches omissions before they reach production.