Copied!
Programming
Laravel
PHP

Multi-Tenant Queues in Laravel – Separate Queues Per Tenant & Preventing Data Leaks

Multi-Tenant Queues in Laravel – Separate Queues Per Tenant & Preventing Data Leaks
Shahroz Javed
Mar 28, 2026 . 77 views

The Multi-Tenant Queue Problem

In a SaaS application, a single queue processes jobs for all tenants. This creates three serious problems:

  • Data leakage risk — a job must know which tenant's database/storage to use. Getting this wrong processes one tenant's data under another tenant's context.

  • Noisy neighbour — Tenant A runs a bulk import of 50,000 records, filling the queue and delaying Tenant B's critical payment jobs by hours.

  • Tenant isolation — one tenant's failing jobs should never block or impact other tenants.

Tenant-Specific Queue Names

The simplest isolation strategy: dispatch each tenant's jobs to a tenant-specific queue name.

// Helper to get the tenant queue name
function tenantQueue(string $queue = 'default'): string
{
    return 'tenant-' . tenant('id') . '-' . $queue;
}

// Dispatching from within a tenant context
SendInvoiceEmail::dispatch($invoice)
    ->onQueue(tenantQueue('emails'));  // e.g. "tenant-42-emails"

GenerateReport::dispatch($report)
    ->onQueue(tenantQueue('reports'));  // e.g. "tenant-42-reports"

Set the Queue on the Job Itself

class SendInvoiceEmail implements ShouldQueue
{
    public function __construct(protected Invoice $invoice) {}

    public function queue(): string
    {
        return 'tenant-' . $this->invoice->tenant_id . '-emails';
    }

    public function handle(): void { /* ... */ }
}

Injecting Tenant Context into Jobs

The core challenge: when a worker picks up a job, the application doesn't know which tenant to run it for. You must store the tenant ID in the job and restore context inside handle().

// App\Jobs\Concerns\HasTenantContext.php (reusable trait)
trait HasTenantContext
{
    public int $tenantId;

    public function initializeTenantContext(): void
    {
        // Store current tenant ID when the job is constructed
        $this->tenantId = tenant('id');
    }

    public function withTenantContext(callable $callback): mixed
    {
        $tenant = Tenant::find($this->tenantId);

        if (! $tenant) {
            $this->delete();  // tenant was deleted
            return null;
        }

        // Switch to this tenant's context (DB, cache, storage)
        return tenancy()->run($tenant, $callback);
    }
}

// Usage in any job
class GenerateTenantReport implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    use HasTenantContext;

    public function __construct(protected int $reportId)
    {
        $this->initializeTenantContext();
    }

    public function handle(): void
    {
        $this->withTenantContext(function () {
            // All code here runs in the correct tenant's context
            $report = Report::find($this->reportId);
            // Uses tenant's database connection, storage disk, etc.
            $report->generate();
        });
    }
}

Shared vs Dedicated Queue Workers

Option 1: Shared Workers with Tenant-Named Queues

Workers listen to all tenant queues using a wildcard-style queue list. Simple but the noisy neighbour problem still exists if one tenant floods their queue.

# Worker processes all tenant queues round-robin
# List specific tenant queues or use a dynamic approach
php artisan queue:work redis --queue=tenant-1-emails,tenant-2-emails,tenant-3-emails,default

Option 2: Dedicated Workers per Tenant (Enterprise)

High-value tenants get dedicated worker processes. This completely eliminates noisy neighbour issues and provides guaranteed processing capacity.

# /etc/supervisor/conf.d/tenant-workers.conf

# Dedicated worker for premium tenant #1
[program:tenant-1-worker]
command=php /var/www/html/artisan queue:work redis --queue=tenant-1-emails,tenant-1-default
numprocs=3
autostart=true
autorestart=true
user=www-data

# Standard worker for all other tenants
[program:shared-tenant-worker]
command=php /var/www/html/artisan queue:work redis --queue=tenant-5-default,tenant-6-default,default
numprocs=2
autostart=true
autorestart=true

Preventing the Noisy Neighbour Problem

Per-Tenant Rate Limiting with Job Middleware

// App\Jobs\Middleware\TenantRateLimit.php
class TenantRateLimit
{
    public function handle(object $job, \Closure $next): void
    {
        $tenantId  = $job->tenantId ?? 0;
        $planLimit = Tenant::find($tenantId)?->plan_queue_limit ?? 60;

        RateLimiter::for("tenant-queue-{$tenantId}", function () use ($planLimit) {
            return Limit::perMinute($planLimit);
        });

        $middleware = new \Illuminate\Queue\Middleware\RateLimited("tenant-queue-{$tenantId}");
        $middleware->handle($job, $next);
    }
}

// Apply to resource-intensive jobs
class ProcessTenantImport implements ShouldQueue
{
    use HasTenantContext;

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

Queue Priorities by Tenant Plan

// Dispatch to different queues based on tenant plan
public function dispatchForTenant(ShouldQueue $job, Tenant $tenant): void
{
    $queue = match($tenant->plan) {
        'enterprise' => 'priority-high',
        'pro'        => 'priority-medium',
        default      => 'priority-low',
    };

    dispatch($job)->onQueue("tenant-{$tenant->id}-{$queue}");
}

// Workers process high-priority queues first
php artisan queue:work --queue=tenant-1-priority-high,tenant-2-priority-high,priority-medium,priority-low

Integration with tenancy-for-laravel

The stancl/tenancy package handles multi-tenancy for Laravel. It provides built-in queue tenancy support so jobs automatically run in the correct tenant context:

// config/tenancy.php
'queue_tenant_identification' => true,

// The package adds a tenant_id to every queued job automatically
// and restores tenant context before handle() runs

// Your job doesn't need manual tenant context code:
class GenerateTenantReport implements ShouldQueue
{
    public function handle(): void
    {
        // tenancy is already initialized — you're in the right tenant's context
        $report = Report::find($this->reportId);
        $report->generate();
    }
}

Conclusion

  • Use tenant-specific queue names (tenant-{id}-emails) for isolation

  • Always store tenant ID in the job and restore context inside handle()

  • Use a reusable HasTenantContext trait to keep jobs clean

  • Use per-tenant rate limiting middleware to prevent noisy neighbour problems

  • High-value tenants on enterprise plans can get dedicated Supervisor worker groups

  • If using stancl/tenancy, enable built-in queue tenant identification for zero boilerplate

📑 On This Page