Laravel Multi-Tenant Row-Level Scoping Guide | 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 Tenant Data Using Row-Level Scoping

 Multi-Tenant SaaS with Laravel: Isolating Tenant Data Using Row-Level Scoping
==============================================================================

 Row-level multi-tenancy keeps your schema simple but demands discipline. Learn how to enforce tenant isolation with a global scope, middleware, and Pest tests that catch leakage before it ships.

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

ShareCopy linkCopied

 ![Multi-Tenant SaaS with Laravel: Isolating Tenant Data Using Row-Level Scoping](https://cdn.msaied.com/594/c38a3d613735b3f43e77683aeb0cce84.png) 

  On this page +1. [Why Row-Level Tenancy Is Still the Right Default](#why-row-level-tenancy-is-still-the-right-default)
2. [Resolving the Current Tenant](#resolving-the-current-tenant)
3. [Middleware That Sets the Context](#middleware-that-sets-the-context)
4. [The Global Scope That Does the Heavy Lifting](#the-global-scope-that-does-the-heavy-lifting)
5. [Testing Isolation with Pest](#testing-isolation-with-pest)
6. [Handling Background Jobs](#handling-background-jobs)
7. [Key Takeaways](#key-takeaways)

 Why Row-Level Tenancy Is Still the Right Default
------------------------------------------------

Schema-per-tenant and database-per-tenant are compelling for strict compliance requirements, but they introduce operational overhead: migration fan-out, connection pool exhaustion, and backup complexity. For most SaaS products, row-level tenancy — a `tenant_id` column on every shared table — is the pragmatic starting point. The risk is data leakage. One missing `WHERE tenant_id = ?` clause and a customer sees another's records. The solution is to make correct behaviour the only easy behaviour.

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

Store the resolved tenant on a singleton so every layer can read it without touching the request object.

```php
// app/Tenancy/TenantContext.php
final class TenantContext
{
    private ?Tenant $current = null;

    public function set(Tenant $tenant): void
    {
        $this->current = $tenant;
    }

    public function get(): Tenant
    {
        return $this->current ?? throw new \RuntimeException('No tenant resolved.');
    }

    public function resolved(): bool
    {
        return $this->current !== null;
    }
}

```

Bind it as a singleton in a `TenancyServiceProvider`:

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

```

Middleware That Sets the Context
--------------------------------

```php
final class ResolveTenantFromSubdomain
{
    public function __construct(private TenantContext $context) {}

    public function handle(Request $request, \Closure $next): mixed
    {
        $host = $request->getHost(); // e.g. acme.app.test
        $slug = explode('.', $host)[0];

        $tenant = Tenant::where('slug', $slug)->firstOrFail();
        $this->context->set($tenant);

        return $next($request);
    }
}

```

Apply it to the `web` and `api` middleware groups, or to a dedicated `tenant` group for routes that require resolution.

The Global Scope That Does the Heavy Lifting
--------------------------------------------

```php
final class TenantScope implements Scope
{
    public function __construct(private TenantContext $context) {}

    public function apply(Builder $builder, Model $model): void
    {
        if ($this->context->resolved()) {
            $builder->where($model->getTable().'.tenant_id', $this->context->get()->id);
        }
    }
}

```

Add a `HasTenant` trait that registers the scope and auto-fills `tenant_id` on creation:

```php
trait HasTenant
{
    protected static function bootHasTenant(): void
    {
        static::addGlobalScope(app(TenantScope::class));

        static::creating(function (Model $model): void {
            $model->tenant_id ??= app(TenantContext::class)->get()->id;
        });
    }
}

```

Apply the trait to every tenant-scoped model. That's the entire enforcement surface.

Testing Isolation with Pest
---------------------------

The most dangerous bug is a query that silently returns cross-tenant rows. Write a Pest dataset test that proves the scope holds:

```php
it('never returns records belonging to another tenant', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    // Seed data under tenant B
    app(TenantContext::class)->set($tenantB);
    Project::factory()->count(3)->create();

    // Query as tenant A — must see zero rows
    app(TenantContext::class)->set($tenantA);
    expect(Project::count())->toBe(0);
});

```

Also test that `withoutGlobalScope` is only reachable from console commands and never from HTTP controllers — an architecture test:

```php
arch('controllers never bypass tenant scope')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Database\Eloquent\Builder::withoutGlobalScope');

```

Handling Background Jobs
------------------------

Jobs run outside the HTTP lifecycle, so the middleware never fires. Serialize the tenant ID into the job and restore the context in the constructor or `handle` method:

```php
final class ProcessInvoice implements ShouldQueue
{
    public function __construct(
        private readonly int $tenantId,
        private readonly int $invoiceId,
    ) {}

    public function handle(TenantContext $context): void
    {
        $context->set(Tenant::findOrFail($this->tenantId));
        // All Eloquent queries from here are scoped.
    }
}

```

Key Takeaways
-------------

- Centralise tenant resolution in a singleton `TenantContext`; never read from `request()` inside models.
- A single `HasTenant` trait on every model is your entire enforcement surface — missing it is a code-review concern, not a runtime one.
- Write a Pest cross-tenant leakage test for every new model; make it part of your PR template.
- Jobs must restore tenant context explicitly — middleware does not run in the queue worker process.
- Use an architecture test to ban `withoutGlobalScope` from HTTP controllers.

- [laravel](https://www.msaied.com/public/articles?search=laravel)
- [multi-tenancy](https://www.msaied.com/public/articles?search=multi-tenancy)
- [saas](https://www.msaied.com/public/articles?search=saas)
- [eloquent](https://www.msaied.com/public/articles?search=eloquent)
- [pest](https://www.msaied.com/public/articles?search=pest)

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

  How do I run console commands that need to operate across all tenants?Loop over all Tenant records, call `app(TenantContext::class)-&gt;set($tenant)` before each iteration, and use `withoutGlobalScope(TenantScope::class)` only in that command class. Keep this pattern isolated to the console layer and enforce it with an architecture test.

   Does this approach work with Filament admin panels?Yes. Register the ResolveTenantFromSubdomain middleware on the Filament panel's middleware stack via `-&gt;middleware(\[ResolveTenantFromSubdomain::class\])` in the panel provider. All Eloquent queries inside Filament resources will then be automatically scoped.

   What happens if a model is missing the HasTenant trait?Queries on that model return all rows regardless of tenant. Add an architecture test using Pest's `arch()` helper to assert that every model in a given namespace uses the HasTenant trait, catching omissions at CI time.

   ![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 articleQuery Binding Masking and whereBinary() in Laravel 13.27](https://www.msaied.com/public/articles/query-binding-masking-and-wherebinary-in-laravel-1327) [Next articleState of Laravel 2026 Survey Is Now Open](https://www.msaied.com/public/articles/state-of-laravel-2026-survey-is-now-open)  

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

1. [Why Row-Level Tenancy Is Still the Right Default](#why-row-level-tenancy-is-still-the-right-default)
2. [Resolving the Current Tenant](#resolving-the-current-tenant)
3. [Middleware That Sets the Context](#middleware-that-sets-the-context)
4. [The Global Scope That Does the Heavy Lifting](#the-global-scope-that-does-the-heavy-lifting)
5. [Testing Isolation with Pest](#testing-isolation-with-pest)
6. [Handling Background Jobs](#handling-background-jobs)
7. [Key Takeaways](#key-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)
