The Problem With "Just Add a tenant_id Column"
Most multi-tenancy tutorials stop at Eloquent global scopes. Real SaaS products have three additional isolation concerns that bite you in production:
- Queue jobs leaking tenant context — a job dispatched for Tenant A runs without knowing which tenant it belongs to.
- Notifications hitting the wrong channel config — Tenant B has a custom Slack webhook; Tenant A does not.
- File uploads colliding — both tenants write
invoices/2024/report.pdfto the same S3 bucket prefix.
Let's solve each one.
1. Carrying Tenant Context Through Queued Jobs
The cleanest approach is a dedicated TenantAware interface combined with a SerializesModels-style middleware on the queue worker.
// app/Contracts/TenantAware.php
interface TenantAware
{
public function getTenantId(): int;
}
// app/Jobs/Concerns/HasTenantContext.php
trait HasTenantContext
{
public int $tenantId;
public function getTenantId(): int
{
return $this->tenantId;
}
}
// app/Queue/Middleware/SetTenantContext.php
use App\Models\Tenant;
use App\Contracts\TenantAware;
class SetTenantContext
{
public function handle(TenantAware $job, callable $next): void
{
$tenant = Tenant::findOrFail($job->getTenantId());
app()->instance('current.tenant', $tenant);
try {
$next($job);
} finally {
app()->forgetInstance('current.tenant');
}
}
}
Any job that uses HasTenantContext and lists SetTenantContext in its middleware() method will have app('current.tenant') available throughout execution — including nested service calls.
Routing Jobs to Tenant-Specific Queues
For high-value tenants on a paid tier you may want dedicated queue workers:
public function queue(): string
{
return match (true) {
$this->tenant()->isPremium() => 'premium',
default => 'default',
};
}
Horizon picks this up automatically if you define a premium supervisor in horizon.php.
2. Per-Tenant Notification Channels
Laravel's notification system resolves channels at runtime, making it straightforward to swap configs:
// app/Notifications/InvoicePaid.php
public function via(object $notifiable): array
{
$tenant = app('current.tenant');
return $tenant->slack_webhook_url
? ['mail', 'slack']
: ['mail'];
}
public function toSlack(object $notifiable): SlackMessage
{
$tenant = app('current.tenant');
return (new SlackMessage)
->to($tenant->slack_webhook_url)
->text('Invoice paid: ' . $this->invoice->number);
}
For tenants that bring their own SMTP credentials, bind a contextual mailer inside a service:
Mail::mailer(
config()->set('mail.mailers.tenant_smtp', [
'transport' => 'smtp',
'host' => $tenant->smtp_host,
'username' => $tenant->smtp_user,
'password' => decrypt($tenant->smtp_password),
]) ?? 'tenant_smtp'
);
Scope this setup to a request/job lifecycle so it never bleeds into other tenants.
3. Isolated File Storage per Tenant
Never let tenants share a flat S3 prefix. Use a dynamic disk resolved from the container:
// app/Services/TenantDisk.php
use Illuminate\Support\Facades\Storage;
class TenantDisk
{
public static function disk(): \Illuminate\Contracts\Filesystem\Filesystem
{
$tenant = app('current.tenant');
$diskName = 'tenant_' . $tenant->id;
if (! config()->has("filesystems.disks.{$diskName}")) {
config([
"filesystems.disks.{$diskName}" => [
'driver' => 's3',
'bucket' => config('filesystems.disks.s3.bucket'),
'root' => "tenants/{$tenant->uuid}",
// inherit key/secret from default s3 config
] + config('filesystems.disks.s3'),
]);
}
return Storage::disk($diskName);
}
}
Usage anywhere in the app:
TenantDisk::disk()->put('invoices/report.pdf', $contents);
Each tenant's files live under tenants/{uuid}/ — simple to audit, easy to delete on churn, and compatible with S3 bucket policies.
Wiring It Into Filament
Filament's panel provider exposes tenant() resolution. Register your context bootstrap there:
public function boot(): void
{
FilamentView::registerRenderHook(
PanelsRenderHook::BODY_START,
fn () => '' // side-effect: tenant already set by panel auth
);
}
For file uploads inside Filament forms, swap the disk on the FileUpload field:
FileUpload::make('attachment')
->disk(fn () => 'tenant_' . app('current.tenant')->id)
->directory('attachments')
Takeaways
- Carry tenant context through jobs via a typed interface + queue middleware, not a global static.
- Resolve notification channels and mailer credentials at runtime from the bound tenant instance.
- Build per-tenant S3 disks lazily using
config()mutation; prefix by UUID, not integer ID. - Filament's
FileUploadaccepts a closure fordisk(), making tenant-aware uploads a one-liner. - Always
forgetInstancein afinallyblock to prevent context leakage in long-lived Octane workers.