Multi-Tenant SaaS with Laravel: Scoping Queries, Resolving Tenants, and Avoiding Data Leaks
#laravel #multi-tenancy #saas #eloquent #architecture

Multi-Tenant SaaS with Laravel: Scoping Queries, Resolving Tenants, and Avoiding Data Leaks

4 min read Mohamed Said Mohamed Said

The Core Problem: Implicit vs. Explicit Tenancy

Most multi-tenant Laravel apps start with a tenant_id column on every table and a where('tenant_id', $current) sprinkled throughout controllers. That works until a developer forgets one clause and a customer sees another customer's data. The fix is to make tenancy structural, not optional.

This article focuses on the single-database, shared-schema model — the most common starting point for SaaS — and shows how to make tenant isolation automatic and testable.


Step 1: Resolve the Tenant Early

Create a TenantResolver service that extracts the tenant from the request (subdomain, custom header, or JWT claim) and binds it into the container as a singleton for the request lifecycle.

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

        return Tenant::where('slug', $subdomain)
            ->firstOrFail();
    }
}

Bind it in a middleware that runs before any route logic:

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

    app()->instance(Tenant::class, $tenant);
    app()->instance('current.tenant', $tenant);

    return $next($request);
}

Register it in bootstrap/app.php (Laravel 11+) or Kernel.php before your route middleware group.


Step 2: Enforce Isolation with a Global Scope

A GlobalScope applied to every tenant-owned model is the safest mechanism. It runs on every SELECT, UPDATE, and DELETE automatically.

// app/Tenancy/TenantScope.php
use Illuminate\Database\Eloquent\{Builder, Model, Scope};

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $tenant = app('current.tenant');

        $builder->where(
            $model->getTable() . '.tenant_id',
            $tenant->id
        );
    }
}

Create a BelongsToTenant trait to keep models clean:

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function (Model $model) {
            if (empty($model->tenant_id)) {
                $model->tenant_id = app('current.tenant')->id;
            }
        });
    }
}

Now any model using this trait is automatically scoped:

class Project extends Model
{
    use BelongsToTenant;
}

// This query is automatically WHERE tenant_id = ? under the hood
$projects = Project::where('status', 'active')->get();

Step 3: The Pitfalls That Leak Data

Queued Jobs

The container binding is request-scoped. A queued job runs in a fresh process with no HTTP request. Always serialize the tenant ID on the job and re-bind it in handle():

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

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);
        app()->instance('current.tenant', $tenant);

        $invoice = Invoice::findOrFail($this->invoiceId); // scoped
    }
}

withoutGlobalScope in Tests

Test helpers that call withoutGlobalScopes() to "simplify" setup silently disable your entire isolation layer. Instead, bind a test tenant in setUp():

beforeEach(function () {
    $this->tenant = Tenant::factory()->create();
    app()->instance('current.tenant', $this->tenant);
});

Raw Queries and DB Facade

Global scopes do not apply to DB::select() or DB::statement(). Audit every raw query and pass tenant_id explicitly, or wrap them in a TenantAwareQuery helper that injects the clause.


Step 4: Verify Isolation with an Architecture Test

// tests/Architecture/TenancyTest.php
arch('tenant-owned models use BelongsToTenant')
    ->expect('App\\Models')
    ->toUseTrait('App\\Tenancy\\BelongsToTenant')
    ->ignoring(['App\\Models\\Tenant', 'App\\Models\\User']);

This Pest architecture test fails CI the moment a developer adds a new model without the trait.


Takeaways

  • Resolve the tenant once in middleware and bind it as a container singleton — never pass it through method arguments.
  • Use a GlobalScope + trait combo so isolation is opt-out, not opt-in.
  • Queued jobs must re-bind the tenant; the HTTP request context does not carry over.
  • Raw DB:: calls bypass global scopes entirely — treat them as a security boundary.
  • An architecture test that enforces trait usage catches omissions before they reach production.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does a GlobalScope affect UPDATE and DELETE statements in Eloquent?
Yes. Eloquent's `update()` and `delete()` methods go through the query builder which applies all registered global scopes, so your tenant_id constraint is included automatically. Direct `DB::update()` calls are not affected.
Q02 How do I temporarily bypass the tenant scope for super-admin operations?
Use `Model::withoutGlobalScope(TenantScope::class)` for a specific query, or `Model::withoutGlobalScopes()` to remove all scopes. Wrap these calls in a dedicated admin service class so they are easy to audit and never leak into normal request paths.
Q03 Should I use separate databases per tenant instead of a shared schema?
Separate databases offer stronger isolation and simpler backups per tenant, but they increase operational complexity significantly (migrations across hundreds of databases, connection pool exhaustion). Shared-schema with global scopes is the right default until you have a compliance or performance reason to switch.

Continue reading

More Articles

View all