Technical Article

Laravel Multi-Tenancy: Four Pieces, No Package

The four pieces of code that keep two sites apart on one Laravel database, the tests behind each, and when stancl/tenancy or Spatie is the better choice.

Share this article

Laravel multi-tenancy comes down to four pieces of code: a place that holds the current tenant, middleware that finds it, a model trait that enforces it, and a queue hook that carries it into background jobs. This website is a multi-tenant Laravel app built exactly that way: one codebase serves mmuzammilq.com and GrowBizNest as separate tenants from one shared database, with no tenancy package. This article walks through each piece as it exists in the code, the tests that hold it in place, and when I would pick a package instead. The order these decisions should be made in is covered in SaaS platform architecture: the decisions that come first.

The shape: one database, a tenant column, four pieces

Every tenant-owned table has a tenant_id column, and 45 models use one trait to enforce it. There is no schema or database per tenant. I chose the shared model because every tenant runs the same schema and the same release, and because the platform operator needs to report across tenants. The cost is that nothing in the database stops a query from reading another tenant’s rows, so the application has to.

PieceLaravel featureJob
Tenant context A scoped container binding Holds the current tenant for one request, job or command
Resolution Route middleware Finds the tenant from the host name, or refuses the request
Enforcement A global scope and model events, in a trait Filters reads, stamps creates, blocks moves
Propagation Queue payload hook and job events Carries the tenant into queued jobs and back out
The four pieces of this site’s tenancy, and the Laravel feature each one is built on.

The tenant context: one scoped object

Everything tenant-aware reads the tenant from one class, TenantContext, never from the request. It is registered as a scoped binding, so Laravel throws it away at the start of every request and every queued job. That matters on long-running workers and under Octane, where a plain singleton would carry one tenant into the next request.

It has three methods worth copying. require() returns the tenant or throws with a message naming what needed it. run() binds a tenant for the length of a callback and restores whatever was bound before, even if the callback throws; seeders and console commands use it. And onChange() lets long-lived services clear their per-tenant caches when the tenant changes. The translator and the post type registry both use it, because a cached value from one site showing on the other is a tenant leak too, just not a database one.

The scoped binding and run(), from this site’s tenancy module.
// TenancyServiceProvider::register()
$this->app->scoped(TenantContext::class);

// TenantContext
public function run(Tenant $tenant, Closure $callback): mixed
{
    $previous = $this->tenant;
    $this->swap($tenant);

    try {
        return $callback($tenant);
    } finally {
        $this->swap($previous);
    }
}

Resolution: the host name, and no fallback

A middleware maps the request’s host to a tenant domain before any route runs. Its rules are short, and each one has a test:

  • An unknown host returns 404. It never falls back to a default tenant.
  • A suspended tenant returns 503.
  • A tenant’s other domains redirect permanently to its primary domain, keeping the path and query, so search engines see one host per site.
  • Host matching is case-insensitive, and the cache holds only the domain’s id, never a serialized model, so a cached entry can’t carry stale tenant data. Moving a domain clears its cache entry.

The no-fallback rule is the one people skip. A default tenant feels harmless in development, where there is only one tenant, and that is exactly why it is dangerous: the code that relies on it looks correct until a second tenant exists.

Enforcement: a trait that fails closed

Each tenant-owned model uses a BelongsToTenant trait. It adds a global scope for reads and model events for writes, and every rule in it fails closed:

The write rules, from the BelongsToTenant trait on this site’s platform.
static::addGlobalScope(new TenantScope);

static::creating(function (Model $model): void {
    $current = app(TenantContext::class)->id();

    if ($current === null) {
        throw TenantNotResolved::forWrite($model::class);
    }

    $model->tenant_id ??= $current;

    if ((int) $model->tenant_id !== $current) {
        throw CrossTenantWrite::for($model::class, $model->tenant_id, $current);
    }
});

static::updating(function (Model $model): void {
    if ($model->isDirty('tenant_id')) {
        throw CrossTenantWrite::reassignment($model::class);
    }
});
  • Reading with no tenant bound throws TenantNotResolved instead of returning every tenant’s rows.
  • Creating with no tenant bound throws. Otherwise the new row is stamped with the bound tenant.
  • Creating a row for a different tenant than the bound one throws CrossTenantWrite.
  • Changing the tenant_id of an existing row throws. A row never moves between tenants.

Platform code that genuinely needs every tenant, such as a super admin report, calls Model::acrossTenants(), which removes the scope. It is a static method with a deliberately loud name, so every use shows up in a search and stands out in review.

Two limits are worth knowing. Global scopes apply to Eloquent queries only: the query builder (DB::table()) and raw SQL bypass them, so tenant-owned tables are only queried through their models. And model events don’t fire for mass updates and deletes run on a query, which is why the trait guards creates and single-row updates, and the scope still filters which rows a mass update can reach. Why fail closed beats fail open is argued in full in Multi-tenant isolation.

Queues: the tenant travels in the payload

A queued job has no host name to resolve a tenant from. The service provider adds the current tenant’s id to every job payload when the job is dispatched, binds that tenant when a worker picks the job up, and restores the previous tenant when the job finishes or fails. A job dispatched with no tenant runs with no tenant, so it can’t touch tenant-owned data by accident: the scope throws.

Carrying the tenant through the queue, simplified from this site’s TenancyServiceProvider.
Queue::createPayloadUsing(fn () => [
    'tenant_id' => app(TenantContext::class)->id(),
]);

Event::listen(JobProcessing::class, function (JobProcessing $event) use (&$previous) {
    $context = app(TenantContext::class);
    $previous[] = $context->current();

    $context->forget();
    if ($id = $event->job->payload()['tenant_id'] ?? null) {
        $context->set(Tenant::query()->findOrFail($id));
    }
});

// JobProcessed and JobExceptionOccurred pop $previous and restore it.

The previous tenant is kept on a stack, not in one variable, because of the sync queue driver. In tests and local development, sync runs the job inside the request that dispatched it, so the request’s tenant has to come back once the job ends.

One trap showed up while writing the test for this. dispatch() returns a pending dispatch that only queues the job when it is destroyed. Inside a short arrow function passed to run(), it can be destroyed after run() has already restored the previous tenant, and the job is queued under the wrong one. The test uses a full closure for that reason, and the comment above it says why.

The tests that hold it in place

Tenancy code is easy to weaken without noticing, so every rule above has a test. These are the ones I would write first on any Laravel platform:

TestWhat it proves
Querying tenant data without a tenant throws The scope fails closed
Creating tenant data without a tenant throws Writes fail closed too
Finding another tenant’s row by id returns nothing Route model binding can’t cross tenants
Creating a row for another tenant throws No cross-tenant writes
A row cannot be moved to another tenant tenant_id is fixed after create
acrossTenants() is an explicit opt-out The escape hatch works and is the only one
run() restores the previous tenant after an exception Seeders and commands can’t leak a tenant
Unknown host is 404 and never falls back Resolution refuses rather than guesses
A job runs with the tenant that dispatched it The queue carries the tenant and restores it
Test names as they appear in this platform’s suite, grouped by the rule they protect.

Laravel multi-tenant packages: build it, or use stancl/tenancy or Spatie?

Two packages cover most Laravel multi-tenancy needs. Tenancy for Laravel (stancl/tenancy) is built around a database per tenant and also supports a single database with a BelongsToTenant trait; it identifies tenants by domain, subdomain, path or request data. Spatie’s laravel-multitenancy is deliberately small: it finds the current tenant, runs configurable tasks when the tenant switches, and can make queued jobs tenant aware.

I built this platform’s tenancy by hand because the shared-database version is small, a handful of classes plus their tests, and I wanted every rule to fail closed and every opt-out to be visible. That trade-off changes with the tenancy model:

SituationWhat I would do
Shared database, tenant column, one team Write the four pieces above; there is little for a package to add
A database or schema per tenant Use stancl/tenancy: switching connections, migrating every tenant database and creating new ones is real work it already does
Existing app that needs a tenant switch with minimal change Spatie’s package, with tasks for the parts that change per tenant
My rule of thumb. Whichever you pick, the tests in the previous section still apply.

Where to go next

Once requests are safely inside one tenant, the next questions are what each user may do and which records they see; multi-tenant access control covers that in Laravel with Spatie’s laravel-permission. If you already run a Laravel platform and want an outside check of its tenancy, the Platform Change-Readiness Scorecard scores tenant isolation in about ten minutes, and a SaaS architecture review goes through the code.

Share this article

References

  1. Laravel documentation: Eloquent global scopes
  2. Tenancy for Laravel (stancl/tenancy) documentation
  3. Spatie laravel-multitenancy documentation

Evidence and further reading

All insights →
InsightSaaS Platform Architecture: The Decisions That Come FirstSeven decisions a multi-tenant platform has to make before features, in the order they bind each other, with what each one cost on four platforms I run.

Facing a similar problem?

Platform & Integration Review

For SaaS founders and engineering leads whose platform is getting slower and riskier to change.

Request an assessment

Let's build what's next

Have a complex system that needs to be built right?

Whether you are starting from an idea, replacing an existing platform, or scaling a system, let's talk.

Better Technology.
Brighter Possibilities.