Multi-Tenant SaaS with Laravel + Filament: Scoping Queries, Panels, and Jobs per Tenant
#laravel #filament #multi-tenancy #saas #architecture

Multi-Tenant SaaS with Laravel + Filament: Scoping Queries, Panels, and Jobs per Tenant

4 min read Mohamed Said Mohamed Said

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 TenantContext is the single source of truth; never read auth()->user()->tenant_id directly in queries.
  • Global scope + trait eliminates per-query where clauses 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Do I need stancl/tenancy to build multi-tenant SaaS in Laravel?
No. For many SaaS products a shared-database, shared-schema approach with a global Eloquent scope and a TenantContext singleton is sufficient and far simpler to debug than a full tenancy package.
Q02 How do I prevent one tenant from accessing another tenant's data in Filament?
Filament's ->tenant() panel configuration combined with a global Eloquent scope ensures every resource query is automatically filtered. Always test with withoutGlobalScopes() in unit tests to confirm the scope is actually applied.
Q03 What is the safest way to pass tenant context into queued jobs?
Store the tenant's primary key as a plain integer property on the job, then restore the TenantContext in a dedicated job middleware. Avoid injecting the full Tenant model to prevent stale serialized data.

Continue reading

More Articles

View all