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

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

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

  [ home ](https://www.msaied.com)    [ articles ](https://www.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%2Fwww.msaied.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%2Fwww.msaied.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://www.msaied.com/articles) 

 [ ![Laravel Queues at Scale: Backpressure, Dead-Letter Queues, and Graceful Degradation](https://cdn.msaied.com/602/fcffaaa5442f84486d6059eaa4106d26.png) laravel queues reliability 

### Laravel Queues at Scale: Backpressure, Dead-Letter Queues, and Graceful Degradation

Beyond basic queue workers: learn how to implement backpressure signals, dead-letter queues, and graceful degr...

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

 28 Aug 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/laravel-queues-at-scale-backpressure-dead-letter-queues-and-graceful-degradation) [ ![Mask Query Bindings in Laravel Exception Messages](https://cdn.msaied.com/603/3011313796d00cd5c4e1ead00e1e9ba1.png) Laravel Security QueryException 

### Mask Query Bindings in Laravel Exception Messages

Laravel 13.27 adds a per-connection option to prevent bound query values from appearing in QueryException mess...

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

 27 Aug 2026     3 min read  

  Read    

 ](https://www.msaied.com/articles/mask-query-bindings-in-laravel-exception-messages) [ ![whereBinary(): How to Run Case-Sensitive MySQL Queries in Laravel 13.27](https://cdn.msaied.com/600/0c7655400b43d3b85d1d1e9d0f4c8094.png) Laravel MySQL Query Builder 

### whereBinary(): How to Run Case-Sensitive MySQL Queries in Laravel 13.27

Laravel 13.27 adds whereBinary(), orWhereBinary(), whereNotBinary(), and orWhereNotBinary() — clean query-buil...

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

 26 Aug 2026     4 min read  

  Read    

 ](https://www.msaied.com/articles/wherebinary-how-to-run-case-sensitive-mysql-queries-in-laravel-1327) 

   [  ![Mohamed Said](https://cdn.msaied.com/01KT78WE565VEMM3PSNQAAB0MH.png)   Mohamed Said Laravel Backend Engineer  ](https://www.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://www.msaied.com)
- [Projects](https://www.msaied.com/projects)
- [Articles](https://www.msaied.com/articles)
- [Certificates](https://www.msaied.com/certificates)
- [Contact](https://www.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)
