Laravel Multi-Tenant: Queue, Storage &amp; Notification Isolation | Mohamed Said       [Skip to content](#main)  [ ![](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png) Mohamed SaidLaravel Backend Engineer ](https://www.msaied.com/public) - [Home](https://www.msaied.com/public)
- [Projects](https://www.msaied.com/public/projects)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [About](https://www.msaied.com/public#about)

           [  Contact](https://www.msaied.com/public#contact) Menu 

Menu
----

Close 

 - [HomeStart here](https://www.msaied.com/public)
- [ProjectsCase studies](https://www.msaied.com/public/projects)
- [ArticlesEngineering notes](https://www.msaied.com/public/articles)
- [CertificatesCredentials](https://www.msaied.com/public/certificates)
- [AboutHow I work](https://www.msaied.com/public#about)
- [ContactGet in touch](https://www.msaied.com/public#contact)

  [Start a conversation](https://www.msaied.com/public#contact) [WhatsApp](https://wa.me/201094619204) [Email](mailto:hello@msaied.com) 

 1. [Home](https://www.msaied.com/public)
2. /
3. [Articles](https://www.msaied.com/public/articles)
4. /
5. Multi-Tenant SaaS with Laravel: Isolating Queues, Storage, and Notifications Per Tenant

 Multi-Tenant SaaS with Laravel: Isolating Queues, Storage, and Notifications Per Tenant
========================================================================================

 Beyond database scoping, true multi-tenant isolation means queues, file storage, and notifications all respect tenant context. Here is how to wire that up cleanly in Laravel without global state leaks.

 ![](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp) [Mohamed Said](https://www.msaied.com/public#person) Published 8 Jul 2026 · Updated 8 Jul 2026 · 3 min read

ShareCopy linkCopied

 ![Multi-Tenant SaaS with Laravel: Isolating Queues, Storage, and Notifications Per Tenant](https://cdn.msaied.com/389/50c273341db2a905f5bc5354ff625722.png) 

  On this page +1. [The Problem With Naive Tenant Scoping](#the-problem-with-naive-tenant-scoping)
2. [The TenantContext Singleton](#the-tenantcontext-singleton)
3. [Carrying Tenant Context Into Queued Jobs](#carrying-tenant-context-into-queued-jobs)
4. [Per-Tenant File Storage](#per-tenant-file-storage)
5. [Per-Tenant Notification Channels](#per-tenant-notification-channels)
6. [Takeaways](#takeaways)

 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.

```php
// 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`:

```php
$this->app->singleton(TenantContext::class);

```

A route middleware resolves the tenant from the subdomain or a header and sets the context:

```php
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:

```php
interface TenantAware
{
    public function getTenantId(): int;
}

```

Then write the middleware:

```php
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:

```php
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:

```php
// 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:

```php
$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 `TenantContext` singleton is the single source of truth; set it in HTTP middleware and job middleware.
- Always call `forget()` in a `finally` block 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 `from` addresses per tenant at the container level, not inside individual notifications.
- The `TenantAware` interface keeps job middleware decoupled from any specific job class.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [multi-tenant](https://www.msaied.com/public/articles?search=multi-tenant)
- [saas](https://www.msaied.com/public/articles?search=saas)
- [queues](https://www.msaied.com/public/articles?search=queues)
- [filament](https://www.msaied.com/public/articles?search=filament)

 Frequently asked questions 
---------------------------

  How do I handle tenant context in scheduled commands?Scheduled commands run without HTTP context. The cleanest approach is to loop over all tenants inside the command itself, calling `app(TenantContext::class)-&gt;set($tenant)` before each tenant's work and `forget()` after, rather than trying to inject context from outside.

   Does Storage::build() create a new S3 client on every call?Yes, it instantiates a new adapter each time. For hot paths, cache the resolved disk instance in a request-scoped binding or a simple array keyed by tenant ID to avoid redundant client construction.

   Can I use separate queue connections per tenant instead of a shared one?Yes. Store a queue connection name on the tenant model and set `$connection` on the job dynamically before dispatching. This gives you per-tenant queue isolation at the infrastructure level, useful when SLA tiers differ across tenants.

   ![Mohamed Said](https://cdn.msaied.com/01M22N44A70A5MC2S599JP0MPH.webp)About the author
----------------

[Mohamed Said](https://www.msaied.com/public#person)Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

[About](https://www.msaied.com/public#about) [GitHub ↗](https://github.com/EG-Mohamed) [LinkedIn ↗](https://www.linkedin.com/in/msaiedm/) [WhatsApp ↗](https://wa.me/201094619204) [Email Address ↗](mailto:hello@msaied.com) [My CV ↗](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)  

   [Previous articleClean Architecture Testing with Pest: Actions, Fakes, and Architectural Assertions](https://www.msaied.com/public/articles/clean-architecture-testing-with-pest-actions-fakes-and-architectural-assertions) [Next articleIntercept: Middleware Guardrails for Laravel AI Agents](https://www.msaied.com/public/articles/intercept-middleware-guardrails-for-laravel-ai-agents)  

   On this page
-------------

1. [The Problem With Naive Tenant Scoping](#the-problem-with-naive-tenant-scoping)
2. [The TenantContext Singleton](#the-tenantcontext-singleton)
3. [Carrying Tenant Context Into Queued Jobs](#carrying-tenant-context-into-queued-jobs)
4. [Per-Tenant File Storage](#per-tenant-file-storage)
5. [Per-Tenant Notification Channels](#per-tenant-notification-channels)
6. [Takeaways](#takeaways)

 ###  Have a technical challenge?

 Tell me what you’re building. I reply within two working days.

[Start a conversation](https://www.msaied.com/public#contact) 

   Related articles
-----------------

 [ ![](https://cdn.msaied.com/743/8998fac3a41451ab3fe1588194e17a43.png) Filament · 3 min read### Securing Filament Plugins with Plumb: Automated Security Scoring for PHP Packages

5 Oct 2026 ](https://www.msaied.com/public/articles/securing-filament-plugins-with-plumb-automated-security-scoring-for-php-packages) [ ![](https://cdn.msaied.com/742/2d02018669cdeedccb5de2efb898f0ee.png) Filament · 3 min read### Filament v3.3.56 Released: File Hash Names and Livewire Upload Fix

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v3356-released-file-hash-names-and-livewire-upload-fix) [ ![](https://cdn.msaied.com/741/5b55c123ad08e4d34e1f4b99ad6a428b.png)  · 3 min read### Filament v4 Schema-Based Forms, Infolists, and the Unified Schema API

5 Oct 2026 ](https://www.msaied.com/public/articles/filament-v4-schema-based-forms-infolists-and-the-unified-schema-api-5) 

  Have a technical challenge?
----------------------------

Tell me what you’re building. I reply within two working days.

 [Discuss your project ↗](https://www.msaied.com/public#contact) 

  © 2026 Mohamed Said · Built with Laravel, meant to last.Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

 - [Home](https://www.msaied.com/public)
- [Articles](https://www.msaied.com/public/articles)
- [Certificates](https://www.msaied.com/public/certificates)
- [GitHub](https://github.com/EG-Mohamed)
- [LinkedIn](https://www.linkedin.com/in/msaiedm/)
- [WhatsApp](https://wa.me/201094619204)
- [Email Address](mailto:hello@msaied.com)
- [My CV](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
- [Sitemap](https://www.msaied.com/public/sitemap.xml)
