The first multi-tenant Laravel app I helped run had three customers sharing a database and a row in a clients table that nobody quite trusted. The fourth customer onboarded, someone forgot a where('client_id', ...) on a reporting query, and the fourth customer saw the third customer's revenue. We caught it in fifteen minutes. It still took a week to recover the relationship.

Multi-tenancy is the architecture decision that punishes small mistakes harder than almost anything else in a Laravel app. Get the boundary right and the same codebase serves a hundred customers cleanly. Get it wrong and a missing scope on one query is a data leak that ends a sales call.

There are three shapes that work. The skill is picking the one your business actually needs and being relentless about enforcing it.

The Three Shapes

Shape one: shared database, tenant_id column. Every multi-tenant table has a tenant_id foreign key. Every query filters by it. One MySQL or Postgres database holds every customer's data. Cheapest to operate, hardest to secure — every query is a potential leak.

Shape two: schema per tenant. Postgres only (effectively). One database, one schema per customer. Same connection, different search_path. You get real isolation without the operational cost of N databases. Backups and migrations get more interesting.

Shape three: database per tenant. Each customer gets their own database (or their own RDS instance). Strongest isolation, easiest to reason about, most operational complexity. Migration and backup tooling has to know about every tenant.

The honest framing is: shape one for SaaS apps where customers are interchangeable rows in a list, shape two when you're on Postgres and care about isolation but don't want N databases, shape three when one tenant could plausibly be Goldman Sachs and isolation is the product.

Once you pick a shape, don't mix. A codebase that does shape one for some tables and shape three for others is the worst of every world. Pick once.

Resolving Which Tenant Is "Current"

Whatever shape you use, every request has to answer one question first: which tenant am I serving? There are two routing patterns:

Domain-based. acme.app.com and globex.app.com are different tenants. A middleware reads the host, looks up the tenant, and binds it.

Path-based. /t/acme/... and /t/globex/... are different tenants. A middleware reads the path segment, looks up the tenant, binds it.

Domain-based looks more professional to customers and gives you isolated cookies. Path-based is simpler to develop locally and avoids wildcard SSL setup. Most teams pick domain-based eventually; it's worth designing for that even if you start path-based.

The middleware is small:

PHP
namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $host   = $request->getHost();
        $tenant = Tenant::query()
            ->where('domain', $host)
            ->orWhereHas('domains', fn ($q) => $q->where('host', $host))
            ->firstOrFail();

        app()->scoped(Tenant::class, fn () => $tenant);
        config(['app.current_tenant_id' => $tenant->id]);

        return $next($request);
    }
}

scoped() is important here — under Octane, singleton() would persist the tenant across requests on the same worker. That's the data leak that ends careers. Either use scoped() (Octane resets it per request) or always resolve from the request, never from the container.

Shape One: tenant_id Done Carefully

In a shared database, the rule is "no query without a tenant filter, ever." Laravel gives you two ways to enforce it.

A global scope on every tenant-owned model:

PHP
namespace App\Models\Concerns;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, $model): void
    {
        $tenantId = config('app.current_tenant_id');
        if ($tenantId) {
            $builder->where("{$model->getTable()}.tenant_id", $tenantId);
        }
    }
}
PHP
class Project extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function (self $project) {
            $project->tenant_id ??= config('app.current_tenant_id');
        });
    }
}

Two things in one place: every read filters by tenant_id, and every write stamps it. The creating hook is what saves you from the "I forgot to set tenant_id on insert" bug, which happens more often than you'd guess.

That's the application-layer guarantee. The defense-in-depth layer is Postgres Row-Level Security (RLS):

SQL
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON projects
    USING (tenant_id = current_setting('app.current_tenant_id')::int);

Then in your tenant middleware:

PHP
DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenant->id]);

(Postgres SET doesn't accept bound parameters, which is why we go through set_config() — same end result, but a query you can actually parameterize.)

Now even if a developer forgets where('tenant_id', ...) in a raw query, Postgres refuses to return rows from other tenants. The application code is the primary boundary; RLS is the last-ditch safety net. Both, not one.

Shape Two And Three: Connection Switching

When tenants live in their own schema or database, the connection itself is the boundary. The middleware swaps the active database connection or search_path based on the resolved tenant:

PHP
// Schema per tenant on Postgres
DB::statement('SET search_path TO tenant_' . $tenant->id . ', public');

// Database per tenant
config([
    'database.connections.tenant.database' => "tenant_{$tenant->id}",
]);
DB::purge('tenant');

A query against the tenant connection now hits the right database. There's no tenant_id column anywhere — it's not needed, because the connection itself can only see one tenant's data.

This is much harder to mess up at the query layer, and much easier to mess up operationally. Every migration has to run against every tenant. Every backup has to cover every tenant. Tenant onboarding becomes "create database and run migrations" instead of "insert row." The packages stancl/tenancy and spatie/laravel-multitenancy both do this for you and are the right starting point — don't roll it yourself unless you have a specific reason.

Diagram with three vertical lanes side by side, each showing one Laravel multi-tenancy pattern. Lane 1: Shared DB — one database icon, multiple tables each with a tenant_id column highlighted, application-layer scope plus Postgres RLS as defense in depth. Lane 2: Schema per tenant — one Postgres database icon containing schemas tenant_1, tenant_2, tenant_3, search_path swap on each request, no tenant_id needed. Lane 3: Database per tenant — three separate database icons, each with full schema, connection switch per request, strongest isolation. Below the lanes, four shared concerns: domain or path routing resolves the tenant, cache keys are prefixed per tenant, queue jobs carry tenant ID and re-bind on handle, RLS as safety net for shape one.
Three shapes, the same operational concerns — routing, cache, queue, and the safety net you keep underneath.

Cache Keys And Queues Are Tenant-Aware Too

The data isn't only in the database. The two places multi-tenant apps leak that nobody warns you about:

Cache. Cache::get('homepage') is the same key for every tenant. If tenant A caches their homepage, tenant B sees it. The fix is either a per-tenant cache prefix:

PHP
// AppServiceProvider boot()
Cache::extend('tenant', function ($app) {
    return Cache::repository(
        new PrefixedStore(
            $app['cache']->store()->getStore(),
            'tenant:' . config('app.current_tenant_id') . ':'
        )
    );
});

…or, more practically, just prefix every key by hand: "tenant:{$tenantId}:homepage". Either works. What doesn't work is forgetting.

Queues. When a controller dispatches a job, the tenant context is in app.current_tenant_id. When the queue worker picks the job up, it has no tenant — workers are long-running processes that don't go through your tenant middleware. Pass the tenant ID into the job and re-bind it inside handle():

PHP
final class GenerateInvoice implements ShouldQueue
{
    public function __construct(public int $tenantId, public int $orderId) {}

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);
        app()->scoped(Tenant::class, fn () => $tenant);
        config(['app.current_tenant_id' => $tenant->id]);

        // ... do the work, every query is scoped correctly now
    }
}

The packages do this for you (stancl/tenancy ships a job middleware that re-binds the tenant). If you're not using a package, the explicit constructor argument is the safest pattern — never a static accessor, never "the queue worker remembers."

Three-panel illustration following the same multi-tenant request from a developer's editor through production runtime to monitoring dashboards, showing where the tenant boundary is enforced at each stage and what it looks like when it holds.
The same boundary, three audiences — code expresses it, production enforces it, monitoring confirms it.

Permissions, Globally And Per-Tenant

Permissions in a multi-tenant app are two-layered. There's the platform layer (which tenants exist, who can create one, who's a billing owner) and the tenant layer (within tenant Acme, who's an admin, who can see the API keys, who can delete a project).

A clean shape: users is platform-level (one user can belong to many tenants), tenant_user is the join with a role per membership, and policies receive both the user and the tenant:

PHP
class ProjectPolicy
{
    public function update(User $user, Project $project): bool
    {
        $tenant = app(Tenant::class);

        if ($project->tenant_id !== $tenant->id) {
            return false; // belt
        }

        return $user->roleIn($tenant)?->can('projects:update') ?? false;
    }
}

Two checks. The first protects against ID-spoofing — even if someone routes to /projects/999/edit where 999 belongs to another tenant, the policy refuses. The second is the role check inside the resolved tenant. Both, always.

spatie/laravel-permission supports tenant-scoped roles via teams; configure it that way and use setPermissionsTeamId() in your tenant middleware so role lookups land in the right scope automatically.

Five-card layout around a central Laravel app icon listing the rules that hold a multi-tenant boundary together — scoped queries, prefixed cache keys, tenant-aware queue jobs, layered policy checks, and Postgres row-level security as the safety net.
Five independent layers — none of them on its own is enough; all of them together is what isolation looks like in Laravel.

Choosing The Shape Honestly

Three honest questions to pick the shape:

  1. What's the cost of leaking one customer's data into another customer's view? If it's "embarrassing email" — shape one is fine. If it's "breach notification" — shape two. If it's "regulator" — shape three.
  2. How big is your largest tenant going to get? A multi-million-row tenant on shape one will eventually start hurting smaller tenants on the same DB. That's the point where shape two or three pays for itself.
  3. How much operational tooling are you willing to build? Shape three needs migration orchestration, per-tenant backups, per-tenant restore, and dashboards that aggregate across N databases. Shape one is just one Laravel app.

There's no answer that's correct in the abstract. There's the answer that fits your customers, your team, and your appetite for operations. The mistake is picking shape one because it's easy and discovering five years in that you've built a bespoke version of shape three on top of it. Re-architecting tenancy on a live SaaS is the worst kind of work.

The good news: Laravel makes any of these shapes workable. The framework's filesystem disks, queue tags, cache stores, connection switching, and global scopes are all tenant-aware if you wire them that way. The only part that isn't automatic is the discipline to never let a query, a job, a cache call, or a webhook escape its tenant context. Build for that discipline up front and the rest is just code.