Multi-Tenant SaaS with Laravel: Isolating Queues, Storage, and Notifications Per Tenant
#laravel #multi-tenant #saas #queues #filament

Multi-Tenant SaaS with Laravel: Isolating Queues, Storage, and Notifications Per Tenant

3 min read Mohamed Said Mohamed Said

The Problem With Naive Tenant Scoping

Most multi-tenant tutorials stop at Eloquent global scopes. That is fine for reads and writes, but a production SaaS has three other surfaces that leak tenant context constantly: queued jobs, file storage, and outbound notifications. Get any of them wrong and you end up with Tenant A's invoice PDF landing in Tenant B's S3 prefix, or a password-reset email sent from the wrong domain.

This article shows a concrete, opinionated approach using a lightweight TenantContext singleton, a job middleware, and a per-tenant filesystem resolver.


The TenantContext Singleton

Bind a simple context object in the service container. It holds the resolved Tenant model for the current request or job.

// app/Tenant/TenantContext.php
final class TenantContext
{
    private ?Tenant $tenant = null;

    public function set(Tenant $tenant): void
    {
        $this->tenant = $tenant;
    }

    public function get(): Tenant
    {
        return $this->tenant ?? throw new \RuntimeException('No tenant in context.');
    }

    public function forget(): void
    {
        $this->tenant = null;
    }
}

Register it as a singleton in AppServiceProvider:

$this->app->singleton(TenantContext::class);

A route middleware resolves the tenant from the subdomain or a header and sets the context:

public function handle(Request $request, Closure $next): mixed
{
    $tenant = Tenant::where('domain', $request->getHost())->firstOrFail();
    app(TenantContext::class)->set($tenant);
    return $next($request);
}

Carrying Tenant Context Into Queued Jobs

Queued jobs run in a separate process with no HTTP context. The cleanest solution is a job middleware that re-hydrates the context before the job executes.

First, make every tenant-aware job implement a small interface:

interface TenantAware
{
    public function getTenantId(): int;
}

Then write the middleware:

final class SetTenantContext
{
    public function handle(TenantAware $job, Closure $next): void
    {
        $tenant = Tenant::findOrFail($job->getTenantId());
        app(TenantContext::class)->set($tenant);

        try {
            $next($job);
        } finally {
            app(TenantContext::class)->forget();
        }
    }
}

A concrete job looks like:

final class GenerateInvoiceJob implements ShouldQueue, TenantAware
{
    use Dispatchable, Queueable;

    public function __construct(private readonly int $tenantId, private readonly int $invoiceId) {}

    public function getTenantId(): int { return $this->tenantId; }

    public function middleware(): array { return [new SetTenantContext]; }

    public function handle(InvoiceService $service): void
    {
        $service->generate($this->invoiceId);
    }
}

The finally block is critical — without it, a failed job leaves stale context in an Octane worker.


Per-Tenant File Storage

Don't hardcode Storage::disk('s3'). Instead, resolve a disk whose root is scoped to the tenant:

// app/Tenant/TenantStorage.php
final class TenantStorage
{
    public function disk(): FilesystemAdapter
    {
        $tenant = app(TenantContext::class)->get();

        return Storage::build([
            'driver' => 's3',
            'bucket' => config('filesystems.disks.s3.bucket'),
            'root'   => "tenants/{$tenant->uuid}",
        ]);
    }
}

Inject TenantStorage wherever you need file access. Every path is automatically namespaced — no chance of cross-tenant reads.


Per-Tenant Notification Channels

For email, bind a tenant-aware mailer in the container:

$this->app->scoped('tenant.mailer', function () {
    $tenant = app(TenantContext::class)->get();

    return Mail::mailer('smtp')->alwaysFrom(
        $tenant->mail_from_address,
        $tenant->mail_from_name,
    );
});

Notifications that resolve app('tenant.mailer') will always use the correct sender identity. For Slack or webhook channels, the same pattern applies — store the webhook URL on the tenant model and resolve it at send time.


Takeaways

  • A TenantContext singleton is the single source of truth; set it in HTTP middleware and job middleware.
  • Always call forget() in a finally block inside job middleware to prevent context leakage in long-lived workers.
  • Build filesystem disks dynamically with Storage::build() so every path is tenant-namespaced without extra configuration.
  • Scope mailer from addresses per tenant at the container level, not inside individual notifications.
  • The TenantAware interface keeps job middleware decoupled from any specific job class.

Found this useful?

Frequently Asked Questions

3 questions
Q01 How do I handle tenant context in scheduled commands?
Scheduled commands run without HTTP context. The cleanest approach is to loop over all tenants inside the command itself, calling `app(TenantContext::class)->set($tenant)` before each tenant's work and `forget()` after, rather than trying to inject context from outside.
Q02 Does Storage::build() create a new S3 client on every call?
Yes, it instantiates a new adapter each time. For hot paths, cache the resolved disk instance in a request-scoped binding or a simple array keyed by tenant ID to avoid redundant client construction.
Q03 Can I use separate queue connections per tenant instead of a shared one?
Yes. Store a queue connection name on the tenant model and set `$connection` on the job dynamically before dispatching. This gives you per-tenant queue isolation at the infrastructure level, useful when SLA tiers differ across tenants.

Continue reading

More Articles

View all