Multi-Tenant Laravel: Scoped Singletons &amp; Tenant 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 in Laravel: Isolating Tenant State with Scoped Singletons

 Multi-Tenant SaaS in Laravel: Isolating Tenant State with Scoped Singletons
============================================================================

 Shared-state bugs are the silent killers of multi-tenant Laravel apps. Learn how scoped singletons, per-request tenant resolution, and Octane-safe guards keep tenant data hermetically isolated.

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

ShareCopy linkCopied

 ![Multi-Tenant SaaS in Laravel: Isolating Tenant State with Scoped Singletons](https://cdn.msaied.com/263/0ead3161989557874b88d47f8a9e023a.png) 

  On this page +1. [The Core Problem: Shared State Across Tenants](#the-core-problem-shared-state-across-tenants)
2. [Resolving the Current Tenant](#resolving-the-current-tenant)
3. [Binding as a Scoped Singleton](#binding-as-a-scoped-singleton)
4. [Consuming Tenant Context Downstream](#consuming-tenant-context-downstream)
5. [Octane Safety](#octane-safety)
6. [Takeaways](#takeaways)

 The Core Problem: Shared State Across Tenants
---------------------------------------------

In a multi-tenant Laravel application, the most dangerous bug is not a 500 — it is Tenant A silently reading Tenant B's data. This happens when tenant context leaks through long-lived singletons, static properties, or carelessly bound services.

The fix is not just global scopes on every model. It starts earlier: **resolving and scoping tenant identity at the container level**, so every service that depends on tenant context receives the right instance for the current request.

Resolving the Current Tenant
----------------------------

Start with a dedicated `TenantContext` value object and a resolver that runs early in the request lifecycle.

```php
// app/Tenant/TenantContext.php
final readonly class TenantContext
{
    public function __construct(
        public readonly int $id,
        public readonly string $slug,
        public readonly string $dbConnection,
    ) {}
}

```

```php
// app/Tenant/TenantResolver.php
final class TenantResolver
{
    public function fromRequest(Request $request): TenantContext
    {
        $host = $request->getHost(); // e.g. acme.app.test
        $slug = explode('.', $host)[0];

        $tenant = Cache::remember("tenant:{$slug}", 60, fn () =>
            Tenant::where('slug', $slug)->firstOrFail()
        );

        return new TenantContext(
            id: $tenant->id,
            slug: $tenant->slug,
            dbConnection: "tenant_{$tenant->id}",
        );
    }
}

```

Binding as a Scoped Singleton
-----------------------------

Laravel's `scoped()` binding was designed for exactly this: a singleton that is **reset on every request** (and on every Octane request cycle).

```php
// app/Providers/TenantServiceProvider.php
public function register(): void
{
    $this->app->scoped(TenantContext::class, function () {
        // Resolved lazily on first use within the request
        throw new RuntimeException('TenantContext must be set before use.');
    });
}

```

Then in a middleware, replace the binding with the real value:

```php
// app/Http/Middleware/SetTenantContext.php
public function handle(Request $request, Closure $next): Response
{
    $context = app(TenantResolver::class)->fromRequest($request);

    // Rebind the scoped singleton for this request
    app()->instance(TenantContext::class, $context);

    // Switch the DB connection so all Eloquent queries use the tenant DB
    config(['database.default' => $context->dbConnection]);
    DB::purge($context->dbConnection);

    return $next($request);
}

```

Register this middleware early in `bootstrap/app.php` (Laravel 11+):

```php
->withMiddleware(function (Middleware $middleware) {
    $middleware->prependToGroup('web', SetTenantContext::class);
    $middleware->prependToGroup('api', SetTenantContext::class);
})

```

Consuming Tenant Context Downstream
-----------------------------------

Any service that needs tenant-aware behaviour simply type-hints `TenantContext`:

```php
final class BillingService
{
    public function __construct(
        private readonly TenantContext $tenant,
        private readonly StripeClient $stripe,
    ) {}

    public function currentBalance(): int
    {
        return Cache::tags(["tenant:{$this->tenant->id}"])
            ->remember('balance', 300, fn () =>
                $this->stripe->balance($this->tenant->id)
            );
    }
}

```

Because `TenantContext` is a scoped singleton, the container always injects the request's resolved instance — no static calls, no `app()` inside the service.

Octane Safety
-------------

Under Octane (Swoole/RoadRunner), workers persist between requests. `scoped()` bindings are flushed automatically at the start of each Octane request cycle via `ScopeMiddleware`, but you must also:

- **Never store tenant state in static properties** on services.
- **Purge DB connections** explicitly (as shown above) — Octane does not reset `config()` between requests.
- **Tag caches** with tenant ID rather than relying on key prefixes alone.

```php
// In your Octane config, ensure scoped bindings are flushed:
// config/octane.php
'flush' => [
    // Octane flushes scoped() automatically, but list any
    // additional singletons that hold per-request state:
    BillingService::class,
],

```

Takeaways
---------

- Use `app()->scoped()` for any service that carries per-tenant state; it resets automatically each request and each Octane cycle.
- Resolve tenant identity in middleware and rebind with `app()->instance()` — keep the resolver itself stateless.
- Type-hint `TenantContext` in downstream services; avoid `app()` or static helpers inside business logic.
- Tag caches with tenant IDs and purge DB connections explicitly when switching contexts.
- Audit for static properties on long-lived services before deploying under Octane.

- [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)
- [architecture](https://www.msaied.com/public/articles?search=architecture)

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

  What is the difference between `singleton()` and `scoped()` in Laravel's container?A `singleton()` binding is resolved once and reused for the entire application lifetime — dangerous in long-lived processes. A `scoped()` binding is also resolved once, but it is flushed and re-resolved at the start of each HTTP request or Octane request cycle, making it safe for per-request state like tenant context.

   Do I still need Eloquent global scopes if I switch the database connection per tenant?If each tenant has a completely separate database, switching the connection is sufficient for data isolation. Global scopes are still useful when tenants share a single database and rows are discriminated by a `tenant\_id` column — they act as a safety net against accidentally unscoped queries.

   How do I handle background jobs that need tenant context?Serialize the `TenantContext` (or just the tenant ID) onto the job payload. In the job's `handle()` method, resolve and rebind the context before executing business logic, then switch the DB connection the same way the middleware does. Never rely on the context being set from a previous request.

   ![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 articleProduction AI Agents in Laravel: Streaming, Token Budgets, and Structured Output Contracts](https://www.msaied.com/public/articles/production-ai-agents-in-laravel-streaming-token-budgets-and-structured-output-contracts) [Next articleNationForge: A Self-Hosted Laravel 12 Admin Panel for Civic Organizations](https://www.msaied.com/public/articles/nationforge-a-self-hosted-laravel-12-admin-panel-for-civic-organizations)  

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

1. [The Core Problem: Shared State Across Tenants](#the-core-problem-shared-state-across-tenants)
2. [Resolving the Current Tenant](#resolving-the-current-tenant)
3. [Binding as a Scoped Singleton](#binding-as-a-scoped-singleton)
4. [Consuming Tenant Context Downstream](#consuming-tenant-context-downstream)
5. [Octane Safety](#octane-safety)
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/740/cce86edc21eddcbdd2f2454fadaf9c70.png)  · 3 min read### The Pipeline Pattern in Laravel: Custom Pipelines Beyond Middleware

5 Oct 2026 ](https://www.msaied.com/public/articles/the-pipeline-pattern-in-laravel-custom-pipelines-beyond-middleware-1) [ ![](https://cdn.msaied.com/739/2d6897fdcdcf090613f96f72a64b8a78.png)  · 4 min read### MySQL Full-Text Search in Laravel: Indexes, Relevance Scoring, and Boolean Mode

4 Oct 2026 ](https://www.msaied.com/public/articles/mysql-full-text-search-in-laravel-indexes-relevance-scoring-and-boolean-mode) [ ![](https://cdn.msaied.com/738/073696a3fefe18bec825beec5ac658f5.png)  · 4 min read### Laravel Queue Rate-Limited Middleware: Throttling Jobs Without Losing Work

4 Oct 2026 ](https://www.msaied.com/public/articles/laravel-queue-rate-limited-middleware-throttling-jobs-without-losing-work) 

  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)
