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:
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:
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);
}
}
}
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):
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:
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:
// 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.
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:
// 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():
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."
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:
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.
Choosing The Shape Honestly
Three honest questions to pick the shape:
- 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.
- 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.
- 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.



