Multi-Tenant SaaS with Laravel + Filament: Scoped Queues, Notifications, and Storage per Tenant
#laravel #filament #multi-tenancy #saas #queues

Multi-Tenant SaaS with Laravel + Filament: Scoped Queues, Notifications, and Storage per Tenant

4 min read Mohamed Said Mohamed Said

The Problem With "Just Add a tenant_id Column"

Most multi-tenancy tutorials stop at Eloquent global scopes. Real SaaS products have three additional isolation concerns that bite you in production:

  1. Queue jobs leaking tenant context — a job dispatched for Tenant A runs without knowing which tenant it belongs to.
  2. Notifications hitting the wrong channel config — Tenant B has a custom Slack webhook; Tenant A does not.
  3. File uploads colliding — both tenants write invoices/2024/report.pdf to the same S3 bucket prefix.

Let's solve each one.


1. Carrying Tenant Context Through Queued Jobs

The cleanest approach is a dedicated TenantAware interface combined with a SerializesModels-style middleware on the queue worker.

// app/Contracts/TenantAware.php
interface TenantAware
{
    public function getTenantId(): int;
}
// app/Jobs/Concerns/HasTenantContext.php
trait HasTenantContext
{
    public int $tenantId;

    public function getTenantId(): int
    {
        return $this->tenantId;
    }
}
// app/Queue/Middleware/SetTenantContext.php
use App\Models\Tenant;
use App\Contracts\TenantAware;

class SetTenantContext
{
    public function handle(TenantAware $job, callable $next): void
    {
        $tenant = Tenant::findOrFail($job->getTenantId());
        app()->instance('current.tenant', $tenant);

        try {
            $next($job);
        } finally {
            app()->forgetInstance('current.tenant');
        }
    }
}

Any job that uses HasTenantContext and lists SetTenantContext in its middleware() method will have app('current.tenant') available throughout execution — including nested service calls.

Routing Jobs to Tenant-Specific Queues

For high-value tenants on a paid tier you may want dedicated queue workers:

public function queue(): string
{
    return match (true) {
        $this->tenant()->isPremium() => 'premium',
        default => 'default',
    };
}

Horizon picks this up automatically if you define a premium supervisor in horizon.php.


2. Per-Tenant Notification Channels

Laravel's notification system resolves channels at runtime, making it straightforward to swap configs:

// app/Notifications/InvoicePaid.php
public function via(object $notifiable): array
{
    $tenant = app('current.tenant');

    return $tenant->slack_webhook_url
        ? ['mail', 'slack']
        : ['mail'];
}

public function toSlack(object $notifiable): SlackMessage
{
    $tenant = app('current.tenant');

    return (new SlackMessage)
        ->to($tenant->slack_webhook_url)
        ->text('Invoice paid: ' . $this->invoice->number);
}

For tenants that bring their own SMTP credentials, bind a contextual mailer inside a service:

Mail::mailer(
    config()->set('mail.mailers.tenant_smtp', [
        'transport' => 'smtp',
        'host'      => $tenant->smtp_host,
        'username'  => $tenant->smtp_user,
        'password'  => decrypt($tenant->smtp_password),
    ]) ?? 'tenant_smtp'
);

Scope this setup to a request/job lifecycle so it never bleeds into other tenants.


3. Isolated File Storage per Tenant

Never let tenants share a flat S3 prefix. Use a dynamic disk resolved from the container:

// app/Services/TenantDisk.php
use Illuminate\Support\Facades\Storage;

class TenantDisk
{
    public static function disk(): \Illuminate\Contracts\Filesystem\Filesystem
    {
        $tenant = app('current.tenant');

        $diskName = 'tenant_' . $tenant->id;

        if (! config()->has("filesystems.disks.{$diskName}")) {
            config([
                "filesystems.disks.{$diskName}" => [
                    'driver' => 's3',
                    'bucket' => config('filesystems.disks.s3.bucket'),
                    'root'   => "tenants/{$tenant->uuid}",
                    // inherit key/secret from default s3 config
                ] + config('filesystems.disks.s3'),
            ]);
        }

        return Storage::disk($diskName);
    }
}

Usage anywhere in the app:

TenantDisk::disk()->put('invoices/report.pdf', $contents);

Each tenant's files live under tenants/{uuid}/ — simple to audit, easy to delete on churn, and compatible with S3 bucket policies.


Wiring It Into Filament

Filament's panel provider exposes tenant() resolution. Register your context bootstrap there:

public function boot(): void
{
    FilamentView::registerRenderHook(
        PanelsRenderHook::BODY_START,
        fn () => '' // side-effect: tenant already set by panel auth
    );
}

For file uploads inside Filament forms, swap the disk on the FileUpload field:

FileUpload::make('attachment')
    ->disk(fn () => 'tenant_' . app('current.tenant')->id)
    ->directory('attachments')

Takeaways

  • Carry tenant context through jobs via a typed interface + queue middleware, not a global static.
  • Resolve notification channels and mailer credentials at runtime from the bound tenant instance.
  • Build per-tenant S3 disks lazily using config() mutation; prefix by UUID, not integer ID.
  • Filament's FileUpload accepts a closure for disk(), making tenant-aware uploads a one-liner.
  • Always forgetInstance in a finally block to prevent context leakage in long-lived Octane workers.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does mutating config() for per-tenant disks cause issues under Laravel Octane?
Yes, if you mutate the global config repository in an Octane worker it persists across requests. Wrap the mutation in a request-scoped singleton or use a dedicated Filesystem manager instance resolved fresh per request to avoid cross-tenant leakage.
Q02 How do I prevent a premium queue worker from accidentally processing default-queue jobs?
In horizon.php, set the premium supervisor's `queue` array to only `['premium']` and ensure its `balance` strategy is `false` or `simple`. This prevents Horizon from auto-balancing default jobs onto premium workers.
Q03 Can I use this pattern with Filament's built-in multi-tenancy (HasTenancy)?
Yes. Filament's HasTenancy trait handles panel-level query scoping. The queue middleware and storage patterns described here are complementary — they extend isolation to async jobs and file I/O that Filament's panel layer never touches.

Continue reading

More Articles

View all