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
TenantContextsingleton is the single source of truth; set it in HTTP middleware and job middleware. - Always call
forget()in afinallyblock 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
fromaddresses per tenant at the container level, not inside individual notifications. - The
TenantAwareinterface keeps job middleware decoupled from any specific job class.