Laravel Multi-Tenant Row-Level Scoping Guide | Mohamed Said        [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://msaied.com) [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles  ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

       [  ](https://github.com/EG-Mohamed)       

 [ Home ](https://msaied.com) [ Projects ](https://msaied.com/projects) [ Articles ](https://msaied.com/articles) [ Certificates ](https://msaied.com/certificates) [ Contact ](https://msaied.com#contact-section) 

  [ home ](https://msaied.com)    [ articles ](https://msaied.com/articles)    Multi-Tenant SaaS with Laravel: Isolating Tenant Data Using Row-Level Scoping        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)

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

  #laravel   #multi-tenancy   #saas   #eloquent   #pest  

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

     26 Aug 2026      3 min read    ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said  

       Table of contents

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

 Found this useful?

          [  ](https://twitter.com/intent/tweet?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fmulti-tenant-saas-with-laravel-isolating-tenant-data-using-row-level-scoping&text=Multi-Tenant+SaaS+with+Laravel%3A+Isolating+Tenant+Data+Using+Row-Level+Scoping) [  ](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fmsaied.com%2Farticles%2Fmulti-tenant-saas-with-laravel-isolating-tenant-data-using-row-level-scoping) 

 Frequently Asked Questions 
----------------------------

  3 questions  

     Q01  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. 

      Q02  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. 

      Q03  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. 

  Continue reading

 More Articles 
---------------

 [ View all    ](https://msaied.com/articles) 

 [ ![Query Binding Masking and whereBinary() in Laravel 13.27](https://cdn.msaied.com/597/bd82bbbaee7d7826a7a3a2f4e8b77330.png) Laravel 13.27 Eloquent Query Builder 

### Query Binding Masking and whereBinary() in Laravel 13.27

Laravel 13.27 ships query binding masking for safer exception messages, a whereBinary() family for byte-exact...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 26 Aug 2026     4 min read  

  Read    

 ](https://msaied.com/articles/query-binding-masking-and-wherebinary-in-laravel-1327) [ ![Laravel Boost v2.6.0: Testing Best Practices Skill and Read-Only DB Transactions](https://cdn.msaied.com/595/80a42be71329f6ac99af4be159b7497d.png) Laravel Boost Testing MCP 

### Laravel Boost v2.6.0: Testing Best Practices Skill and Read-Only DB Transactions

Laravel Boost v2.6.0 ships a unified testing-best-practices skill for AI coding agents, database-enforced read...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 26 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-boost-v260-testing-best-practices-skill-and-read-only-db-transactions) [ ![Laravel Auditor: AI-Powered Code Auditing for Laravel Applications](https://cdn.msaied.com/593/e1204fbf1f19082d6afc53717375ca16.png) Laravel AI Code Auditing 

### Laravel Auditor: AI-Powered Code Auditing for Laravel Applications

Laravel Auditor gives your existing AI agent a written audit methodology, 75 stable rules, and read-only proje...

  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MJ.jpg)  Mohamed Said 

 24 Aug 2026     3 min read  

  Read    

 ](https://msaied.com/articles/laravel-auditor-ai-powered-code-auditing-for-laravel-applications) 

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

Explore

- [Home](https://msaied.com)
- [Projects](https://msaied.com/projects)
- [Articles](https://msaied.com/articles)
- [Certificates](https://msaied.com/certificates)
- [Contact](https://msaied.com#contact-section)

Connect

- [   hello@msaied.com ](mailto:hello@msaied.com)
- [   +20 109 461 9204 ](tel:+201094619204)

© 2026 Mohamed Said. All rights reserved.

 [  ](https://github.com/EG-Mohamed) [  ](https://www.linkedin.com/in/msaiedm/) [  ](https://wa.me/201094619204) [  ](mailto:hello@msaied.com) [  ](https://drive.google.com/file/u/0/d/1MF20IPRJyzfy32mhEutjL5EpSls0w2Q8/view)
