Let's Connect

A laptop on a desk showing a software dashboard, representing a multi-tenant SaaS application serving many customers from one codebase

You can build Laravel multi-tenancy without a package, and for a shared-database SaaS you usually should. There are two moving parts: a resolver that decides which tenant a request belongs to, and a global scope that pins every query to that tenant. That is roughly 120 lines you can read in one sitting and audit line by line, versus a package whose query-scoping and connection-swapping internals you inherit on faith. The setup is a middleware that resolves the tenant and binds it into a request-scoped singleton, plus a BelongsToTenant trait that adds tenant_id to every query and every insert automatically. One missing where clause is a cross-tenant data leak, so the goal is to make forgetting impossible rather than relying on discipline.

Should I really skip the package?

Packages like stancl/tenancy are excellent. If you need landlord databases, per-tenant migration orchestration, domain management, and event-driven bootstrappers, reach for one. But a lot of B2B SaaS apps are a single shared database with a tenant_id column on the tenant-owned tables. For that shape, a package is a lot of surface area to debug when a query leaks across tenants, and when the leak happens you will be reading the package source anyway. Rolling the resolver yourself keeps the security-critical path small and inside your own repo. The architectural decision of shared-database versus database-per-tenant, with its isolation, noisy-neighbor, and compliance tradeoffs, is a separate question I cover in my multi-tenant SaaS architecture post. This article is the concrete code once you have decided on a shared database, with a note at the end on extending it to database-per-tenant.

How does the resolver identify the tenant?

Resolve the tenant from something the request cannot freely lie about. The two honest signals are the hostname (subdomain or custom domain) and, after login, the authenticated user's tenant_id. The rule that keeps you safe: never trust raw request input. No ?tenant_id= query param, no X-Tenant-Id header that any client can set. You resolve by hostname, and if a user is also authenticated you verify that the user actually belongs to the tenant the hostname points at. Both paths end at the same place: a Tenant model bound into the container so the rest of the request can read it without re-resolving.

First, a small holder for the current tenant. Binding it as a scoped instance means it lives for exactly one request and gets a fresh instance per queued job.

app/Tenancy/TenantContext.php
<?php

namespace App\Tenancy;

use App\Models\Tenant;

class TenantContext
{
    protected ?Tenant $tenant = null;

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

    public function get(): ?Tenant
    {
        return $this->tenant;
    }

    public function id(): ?int
    {
        return $this->tenant?->id;
    }

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

Bind it as a scoped instance in a service provider so a single object is shared for the lifetime of one request or job, then reset between them. In Laravel 11/12 the application bootstraps from bootstrap/app.php, but service providers still live in app/Providers.

app/Providers/AppServiceProvider.php
public function register(): void
{
    // scoped() = one instance per request and per queued job,
    // reset by the framework between them. Never use singleton()
    // here, or a worker would carry tenant A into tenant B's job.
    $this->app->scoped(\App\Tenancy\TenantContext::class);
}

Now the resolver middleware. It resolves the tenant from the hostname first. If a user is also authenticated, it then confirms that user belongs to the resolved tenant, so a logged-in user cannot operate against another tenant's subdomain. It aborts hard rather than falling through to an unscoped state.

app/Http/Middleware/ResolveTenant.php
<?php

namespace App\Http\Middleware;

use App\Models\Tenant;
use App\Tenancy\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class ResolveTenant
{
    public function __construct(protected TenantContext $context) {}

    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $this->resolveFromHost($request);

        abort_if($tenant === null, 404, 'Tenant not found.');

        // If a user is authenticated, they must belong to the tenant
        // the hostname resolved to. This is what stops a logged-in
        // user from operating against another tenant's subdomain.
        if ($request->user() && $request->user()->tenant_id !== $tenant->id) {
            abort(403, 'Tenant mismatch.');
        }

        $this->context->set($tenant);

        return $next($request);
    }

    protected function resolveFromHost(Request $request): ?Tenant
    {
        // Resolve by subdomain: acme.app.test -> 'acme'.
        $host = $request->getHost();
        $base = config('tenancy.base_domain'); // e.g. 'app.test'

        if (! str_ends_with($host, '.'.$base)) {
            return null; // custom domains would be looked up here instead
        }

        $subdomain = substr($host, 0, -(strlen($base) + 1));

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

For a token-based API with no per-tenant hostname, drop the host check and resolve straight from the authenticated user: Tenant::find($request->user()->tenant_id). The key invariant is the same either way, the tenant comes from an honest signal, never from client-supplied input. Register the middleware in bootstrap/app.php as a named alias, then attach it to the web or api group after the authentication middleware so $request->user() is populated when the resolver runs.

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'tenant' => \App\Http\Middleware\ResolveTenant::class,
    ]);
})
Source code on a dark editor screen, representing the small auditable surface of a hand-rolled tenant scope versus a third-party package
The whole tenant-scoping surface fits on one screen, which is the point. You can audit it in a code review.

How do I stop one missing where clause from leaking data?

This is the part you do not leave to discipline. A single Invoice::where('status', 'paid')->get() with no tenant filter returns every tenant's paid invoices. The defense is a global scope applied through a trait, so the tenant_id condition is attached to every query automatically and tenant_id is filled on every insert. You add one trait to a model and that model can no longer be queried unscoped by accident.

app/Models/Concerns/BelongsToTenant.php
<?php

namespace App\Models\Concerns;

use App\Tenancy\TenantContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        $context = app(TenantContext::class);

        // 1. Filter every read by the current tenant.
        static::addGlobalScope(new class ($context) implements Scope {
            public function __construct(private TenantContext $context) {}

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

        // 2. Stamp tenant_id on every insert so you cannot forget it.
        static::creating(function (Model $model) use ($context) {
            if ($context->check() && empty($model->tenant_id)) {
                $model->tenant_id = $context->id();
            }
        });
    }
}

Two implementation details that bite people if you skip them:

  • Qualify the column as $model->getTable().'.tenant_id'. The moment you join two tenant-scoped tables, an unqualified tenant_id becomes an ambiguous-column SQL error. Qualifying it from the start saves a confusing debugging session later.
  • Guard on $context->check(). During console commands, migrations, or seeders there is no resolved tenant, and you do not want the scope to silently inject tenant_id = null and return zero rows. When you genuinely need cross-tenant access, an admin report or a nightly aggregate, call Model::withoutGlobalScope() explicitly, and make that call loud, because every unscoped query is a place a leak can hide.
  • Add a tenant_id foreign key and a composite index on (tenant_id, <frequently filtered column>) for every tenant table. Every query now carries a tenant_id predicate, so the index earns its keep. More on why in my database indexing guide.

Using it is a one-liner on the model:

app/Models/Invoice.php
class Invoice extends Model
{
    use \App\Models\Concerns\BelongsToTenant;
}
Treat every unscoped query as a future incident report: the global scope is the only thing standing between a typo and tenant A reading tenant B's invoices.Md Raihan Hasan

What changes for database-per-tenant?

If you went with a database per tenant instead of a shared schema, you do not need the global scope at all, because isolation comes from the connection itself. The resolver does the same identification, but instead of binding a tenant_id it rewrites the tenant connection config and reconnects before any query runs. The critical part is purging the cached connection: Laravel caches the resolved PDO connection, so changing config alone does nothing until you DB::purge() and reconnect.

app/Tenancy/SwitchTenantDatabase.php
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;

public function makeCurrent(Tenant $tenant): void
{
    // Point the 'tenant' connection at this tenant's database.
    Config::set('database.connections.tenant.database', $tenant->db_name);

    // Drop the cached PDO so the next query opens the new database.
    DB::purge('tenant');
    DB::reconnect('tenant');

    // Make it the default so models with no explicit connection use it.
    DB::setDefaultConnection('tenant');
}

The trap here is forgetting to purge. You switch the config, the next query still hits the previous tenant's database because the old PDO is cached, and you get a cross-tenant leak that only shows up under specific request ordering. Always purge, then reconnect.

How do I carry tenant context into queued jobs?

This is the silent failure mode of hand-rolled tenancy. TenantContext is scoped to a request, so when a job runs on a worker minutes later there is no resolved tenant, the global scope sees check() == false, and queries return everything. Never serialize the whole Tenant model into the job, it goes stale and bloats the payload. Serialize the tenant id, then re-resolve and set the context at the top of handle().

app/Jobs/GenerateInvoicePdf.php
class GenerateInvoicePdf implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $invoiceId,
        public int $tenantId, // capture at dispatch time
    ) {}

    public function handle(TenantContext $context): void
    {
        // Rebind the tenant before any scoped model is touched.
        $context->set(Tenant::findOrFail($this->tenantId));

        $invoice = Invoice::findOrFail($this->invoiceId); // now tenant-scoped
        // ... render the PDF
    }
}

Capture $context->id() when you dispatch, in the request where the tenant is known. If you have many tenant-aware jobs, push this into a base job class so you set the context once rather than at the top of every handle(). I go deeper on running these reliably in my Laravel queue workers on production write-up.

How do I prove tenant B cannot read tenant A's data?

Untested isolation is not isolation. The single most valuable test asserts that with tenant B as the active context, tenant A's rows are invisible, both through normal queries and through the auto-fill on create. Run it on every CI build.

tests/Feature/TenantIsolationTest.php
use App\Models\Invoice;
use App\Models\Tenant;
use App\Tenancy\TenantContext;

it('hides tenant A data when tenant B is active', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    $context = app(TenantContext::class);

    // Seed an invoice for tenant A.
    $context->set($a);
    $invoiceA = Invoice::create(['amount' => 100]);
    expect($invoiceA->tenant_id)->toBe($a->id); // auto-filled

    // Switch to tenant B and confirm A's row is invisible.
    $context->set($b);
    expect(Invoice::find($invoiceA->id))->toBeNull();
    expect(Invoice::count())->toBe(0);

    // A create as B is stamped with B, never A.
    $invoiceB = Invoice::create(['amount' => 50]);
    expect($invoiceB->tenant_id)->toBe($b->id);
});

That test is the contract. If it ever goes red, something bypassed the scope, a raw DB::table() query, a withoutGlobalScope() that escaped review, or a relationship loaded across connections, and you want to find out in CI, not from a customer who saw someone else's invoices.

The reason to build this yourself is not avoiding a dependency for its own sake. It is that tenancy is the one part of a SaaS where a silent bug is a breach, and you want that code small enough to hold in your head. A resolver that reads the tenant from an honest signal, a trait that makes every query and insert tenant-aware by default, an explicit and loud escape hatch for the rare cross-tenant job, and an isolation test wired into CI. Keep those four pieces tight and your tenancy layer stays auditable, debuggable, and yours, with no guessing what the package does at 2am when a query returns a row it never should have.