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