rylxes / laravel-tenant-jobs
Bulletproof multi-tenant queue job handling for Laravel. Fixes context leaking, facade singletons, scheduled dispatch, retry context, batch propagation, and queued notifications across stancl/tenancy and spatie/laravel-multitenancy.
Requires
- php: ^8.1
- illuminate/bus: ^10.0|^11.0|^12.0
- illuminate/queue: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
- rylxes/laravel-multitenancy: Recommended: first-class integration with zero-config setup (^1.0)
- spatie/laravel-multitenancy: Required if using the Spatie tenant resolver (^3.0|^4.0)
- stancl/tenancy: Required if using the Stancl tenant resolver (^3.0)
README
Bulletproof multi-tenant queue job handling for Laravel. Fixes the six most common queue problems in multi-tenant applications.
Recommended: Use with rylxes/laravel-multitenancy for zero-config integration. Also supports stancl/tenancy and spatie/laravel-multitenancy.
The Problem
Queue workers are long-running daemons. Unlike HTTP requests (which boot fresh per request), a queue worker boots once and processes hundreds of jobs sequentially. Any tenant state left over from one job bleeds into the next — database connections, cache prefixes, filesystem paths, facade singletons. All globally mutable in a single PHP process.
This package fixes all six known failure modes:
| # | Problem | What happens |
|---|---|---|
| 1 | Context leaking between jobs | Tenant 1's DB connection stays active when Tenant 2's job runs |
| 2 | Facade singletons not reset | Storage, Mail, Cache facades retain the previous tenant's config |
| 3 | Scheduled jobs have no tenant context | Cron runs in central context — no way to dispatch per-tenant |
| 4 | Failed job retry loses tenant context | queue:retry doesn't restore the tenant |
| 5 | Batch callbacks lose tenant context | then()/catch()/finally() run in the wrong tenant |
| 6 | Queued notifications silently don't run | Tenant-aware notifications sit in the queue unprocessed |
Requirements
- PHP 8.1+
- Laravel 10, 11, or 12
- One of:
rylxes/laravel-multitenancy(^1.0, recommended),spatie/laravel-multitenancy(^3.0|^4.0), orstancl/tenancy(^3.0)
Installation
composer require rylxes/laravel-tenant-jobs
The package auto-registers its service provider via Laravel's package discovery.
Publish the config file:
php artisan vendor:publish --tag=tenant-jobs-config
Configuration
// config/tenant-jobs.php return [ // 'auto' detects installed tenancy package. // Or force: 'multitenancy', 'spatie', 'stancl', or a custom FQCN. // Detection order: rylxes/laravel-multitenancy > stancl/tenancy > spatie/laravel-multitenancy 'resolver' => 'auto', // Auto-apply tenant context to all queued jobs via event listeners. 'auto_apply_middleware' => true, // Key used to stamp tenant ID into job payloads. 'payload_key' => 'tenant_id', // Facade accessors to clear between jobs. 'facades_to_clear' => ['storage', 'log', 'mail', 'cache'], // Container singletons to re-resolve between jobs. 'services_to_reset' => ['filesystem.disk', 'cache.store', 'mailer'], // Purge DB connections between jobs (recommended for per-tenant DBs). 'purge_db_connections' => true, // Specific connection to purge, or null for all non-default. 'tenant_db_connection' => null, // Delay between per-tenant scheduled dispatches (prevents thundering herd). 'schedule_stagger_seconds' => 1, ];
How It Works
The package uses a two-layer defense:
- Event listeners (
JobProcessing/JobProcessed/JobFailed) provide global coverage — every job gets tenant context initialized from its payload and cleaned up after. - Job middleware (
TenantJobMiddleware) wraps execution intry/finallyfor guaranteed cleanup even on exceptions.
A TenantResolver interface abstracts over all supported tenancy packages so the same code works with any of them.
Usage
Zero-config (auto mode)
With auto_apply_middleware => true (the default), every queued job automatically gets tenant context. If a job was dispatched while a tenant was active, the PayloadStamper stamps the tenant_id into the payload. When the worker picks it up, the event listener restores the tenant context. After the job finishes, everything is cleaned up.
You don't need to change your existing jobs.
Problem 1 & 2: Context Leaking + Facade Singletons
Handled automatically. After every job, the package:
- Calls
forgetCurrentTenant()on the resolver - Clears configured facade instances (
Storage,Mail,Cache,Log) - Forgets container singletons for tenant-specific services
- Optionally purges database connections
Problem 3: Scheduled Jobs Have No Tenant Context
Use TenantSchedule instead of Laravel's Schedule::job():
// app/Console/Kernel.php (Laravel 10) // or bootstrap/app.php Schedule callback (Laravel 11+) use TenantJobs\Schedule\TenantSchedule; $tenantSchedule = app(TenantSchedule::class); // Dispatches GenerateReport for EVERY tenant, with staggered delay $tenantSchedule->job(new GenerateMonthlyReport()) ->monthlyOn(1, '03:00'); // Run a callback in each tenant's context $tenantSchedule->call(function () { // This runs once per tenant with the correct context CleanupExpiredData::dispatch(); })->daily(); // Run an artisan command per tenant $tenantSchedule->command('tenant:cleanup') ->weeklyOn(1, '04:00');
At execution time, TenantSchedule iterates over all tenants, runs each job/callback within that tenant's context, and adds a configurable stagger delay to prevent thundering herd.
Problem 4: Failed Job Retry Loses Tenant Context
Handled automatically. The PayloadStamper puts tenant_id at the top level of the JSON payload. When a job fails, this survives in the failed_jobs.payload column. When you run queue:retry, the package listens to JobRetryRequested and restores the tenant context.
# This just works — tenant context is restored automatically
php artisan queue:retry 5
php artisan queue:retry all
Problem 5: Batch Callbacks Lose Tenant Context
Wrap your batch with BatchContextPropagator:
use TenantJobs\Support\BatchContextPropagator; // The tenant ID is captured at dispatch time and restored in callbacks BatchContextPropagator::wrap(Bus::batch([ new ProcessInvoice($invoice1), new ProcessInvoice($invoice2), ]))->then(function (Batch $batch) { // Runs in the correct tenant context Notification::send($admin, new BatchComplete()); })->catch(function (Batch $batch, Throwable $e) { // Also in the correct tenant context Log::error('Batch failed', ['batch' => $batch->id]); })->dispatch();
Or use the convenience trait:
use TenantJobs\Concerns\TenantAwareBatch; class ProcessAllInvoices implements ShouldQueue { use TenantAwareBatch; public function handle() { $this->tenantBatch([ new ProcessInvoice($invoice1), new ProcessInvoice($invoice2), ])->then(function (Batch $batch) { // Correct tenant context guaranteed })->dispatch(); } }
Problem 6: Queued Notifications Silently Don't Run
Add the TenantAwareNotification trait to your notification and call captureTenantId() in the constructor:
use Illuminate\Notifications\Notification; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Bus\Queueable; use TenantJobs\Concerns\TenantAwareNotification; class InvoicePaid extends Notification implements ShouldQueue { use Queueable, TenantAwareNotification; public function __construct(private Invoice $invoice) { $this->captureTenantId(); } public function via($notifiable): array { return ['mail', 'database']; } // ... your notification methods }
The trait captures the tenant ID at construction time, and provides middleware that restores it when the queued SendQueuedNotifications job processes.
Central Jobs (RunsCentrally)
For jobs that must run in the central/landlord context regardless of worker state, implement RunsCentrally:
use TenantJobs\Concerns\RunsCentrally; class PruneExpiredTokens implements ShouldQueue, RunsCentrally { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; public function handle() { // Guaranteed clean central context — // even if the previous job left Tenant 5 active } }
Unlike passively skipping tenant bootstrapping, RunsCentrally actively calls forgetCurrentTenant() before the job runs.
Explicit Middleware (opt-in mode)
If you set auto_apply_middleware => false in config, you can apply the middleware per-job:
use TenantJobs\Middleware\TenantJobMiddleware; class ProcessOrder implements ShouldQueue { public string|int|null $tenantId = null; public function middleware(): array { return [app(TenantJobMiddleware::class)]; } }
Integration with rylxes/laravel-multitenancy
If you use rylxes/laravel-multitenancy, integration is zero-config:
composer require rylxes/laravel-multitenancy rylxes/laravel-tenant-jobs
The ResolverFactory auto-detects rylxes/laravel-multitenancy as the preferred resolver. No configuration needed — tenant context is automatically preserved across all queued jobs, retries, batches, and notifications.
Custom Tenant Resolver
If you're not using a supported package, implement the TenantResolver interface:
use TenantJobs\Contracts\TenantResolver; class MyCustomResolver implements TenantResolver { public function getCurrentTenantId(): string|int|null { /* ... */ } public function setCurrentTenant(string|int $tenantId): void { /* ... */ } public function forgetCurrentTenant(): void { /* ... */ } public function getAllTenantIds(): iterable { /* ... */ } public function runAsTenant(string|int $tenantId, callable $callback): mixed { /* ... */ } public function getTenantIdFromPayload(array $payload): string|int|null { /* ... */ } }
Set it in config:
'resolver' => App\Tenancy\MyCustomResolver::class,
Architecture
src/
TenantJobsServiceProvider.php — Orchestrates all components
Contracts/TenantResolver.php — Abstraction over tenancy packages
Resolvers/
MultitenancyTenantResolver.php — rylxes/laravel-multitenancy adapter (preferred)
SpatieTenantResolver.php — spatie/laravel-multitenancy adapter
StanclTenantResolver.php — stancl/tenancy adapter
ResolverFactory.php — Auto-detection + custom resolver
Middleware/TenantJobMiddleware.php — try/finally cleanup per-job
Concerns/
RunsCentrally.php — Marker interface for central jobs
TenantAwareNotification.php — Trait for queued notifications
TenantAwareBatch.php — Trait for batch dispatch
Schedule/TenantSchedule.php — Per-tenant scheduled dispatch
Support/
PayloadStamper.php — Stamps tenant_id into payloads
FacadeResetter.php — Clears facade/singleton state
RetryContextPreserver.php — Restores tenant on retry
BatchContextPropagator.php — Wraps batch callbacks
TenantAwarePendingBatch.php — Decorated PendingBatch
Testing
composer test # or ./vendor/bin/phpunit
License
MIT. See LICENSE.