The Problem With "Just Add a tenant_id Column"
Every multi-tenant SaaS starts the same way: a tenant_id column on every table and a where('tenant_id', auth()->user()->tenant_id) sprinkled everywhere. That works until a junior dev forgets the clause, a background job runs without an authenticated user, or you need per-tenant Filament panels. This article shows a self-contained approach — no stancl/tenancy required — that scales to real production use.
1. Resolving the Current Tenant Early
Create a TenantContext singleton that the rest of the app reads from:
// app/Tenancy/TenantContext.php
final class TenantContext
{
private ?Tenant $current = null;
public function set(Tenant $tenant): void
{
$this->current = $tenant;
}
public function get(): Tenant
{
return $this->current ?? throw new RuntimeException('No tenant resolved.');
}
public function resolved(): bool
{
return $this->current !== null;
}
}
Register it as a singleton in AppServiceProvider, then resolve it in a middleware:
// app/Http/Middleware/ResolveTenant.php
public function handle(Request $request, Closure $next): Response
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app(TenantContext::class)->set($tenant);
return $next($request);
}
Apply this middleware globally or to your web / api groups — before any controller runs.
2. Automatic Query Scoping via a Global Scope
Rather than a trait that adds where clauses manually, use a reusable global scope:
// app/Tenancy/BelongsToTenantScope.php
class BelongsToTenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (app(TenantContext::class)->resolved()) {
$builder->where(
$model->getTable() . '.tenant_id',
app(TenantContext::class)->get()->id
);
}
}
}
Add a BelongsToTenant trait to every tenant-scoped model:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new BelongsToTenantScope());
static::creating(function (Model $model) {
$model->tenant_id ??= app(TenantContext::class)->get()->id;
});
}
}
Now Project::all() automatically returns only the current tenant's rows, and new records are stamped on creation.
3. Per-Tenant Filament Panels
Filament v3+ supports multiple panels. Use a panel per product tier or a single panel with tenant-aware auth:
// app/Providers/Filament/AppPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
->id('app')
->domain(fn () => request()->getHost()) // dynamic domain
->authMiddleware([ResolveTenant::class, Authenticate::class])
->tenant(Tenant::class, slugAttribute: 'subdomain')
->tenantMiddleware([ResolveTenant::class], isPersistent: true);
}
Filament's built-in ->tenant() call wires the panel's resource queries to the resolved tenant automatically — it calls withTenantScope() on every resource query, which delegates to your global scope.
4. Isolating Background Jobs
The biggest footgun: a queued job runs without HTTP context, so TenantContext is empty. Solve it with a job middleware and a serializable tenant reference:
// app/Jobs/Middleware/SetTenantContext.php
class SetTenantContext
{
public function handle(object $job, Closure $next): void
{
if (property_exists($job, 'tenantId')) {
$tenant = Tenant::findOrFail($job->tenantId);
app(TenantContext::class)->set($tenant);
}
$next($job);
}
}
In every tenant-aware job:
class GenerateMonthlyReport implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
public int $tenantId;
public function __construct(Tenant $tenant)
{
$this->tenantId = $tenant->id;
}
public function middleware(): array
{
return [new SetTenantContext()];
}
public function handle(): void
{
// Project::all() is now scoped to the correct tenant
}
}
For Horizon, use separate queues per tier (tenant-free, tenant-pro) and configure supervisor groups accordingly — this gives you resource fairness without a full queue-per-tenant setup.
Key Takeaways
- Singleton
TenantContextis the single source of truth; never readauth()->user()->tenant_iddirectly in queries. - Global scope + trait eliminates per-query
whereclauses and prevents data leaks by default. - Filament's
->tenant()integrates cleanly with your global scope — no duplicate scoping logic. - Job middleware is the correct place to restore tenant context in async workers, not the job constructor.
- Keep tenant resolution in middleware, not in models or service classes, so it happens once per request lifecycle.