Multi-tenancy is the most important invariant in the codebase. A bug that leaks data across workspace boundaries is a security incident. The enforcement is layered: traits, global scopes, policies, and a regression test that fails the build.
Two traits do the heavy lifting:
App\Concerns\BelongsToWorkspace — for models with a direct workspace_id column.App\Concerns\BelongsToAgent — for models that belong to an agent (and transitively to that agent's workspace). The trait's global scope joins through agents to filter by agents.workspace_id = current.
Both traits add a global scope that's applied to every
Eloquent query against the model. They also auto-fill
workspace_id on creating, so you can't accidentally insert
a row into the wrong workspace.
The current workspace is resolved per request by middleware:
users.default_workspace_id, verify membership, set the singleton.agent_id claim, look up the agent, set the singleton from agents.workspace_id.
The resolver lives at App\Support\CurrentWorkspace. After
the request, the middleware's finally block clears it so
state can't leak between Octane requests.
Some queries genuinely need to look across workspaces — admin reports, impersonation, system jobs. The bypass is explicit:
// System-level operation: rolling up usage across all workspaces
Workspace::withoutWorkspaceScope()->each(...)
The convention is every withoutWorkspaceScope()
call must have a justifying comment immediately above it. Code
review enforces this; the /tenancy audit slash command
flags violations.
A user with multiple memberships uses the workspace switcher in the
sidebar. Switching POSTs to /workspaces/{id}/select, which
updates users.default_workspace_id and redirects. The
next page load resolves the new workspace.
The switcher only renders when the user has 2+ memberships — single- workspace users see a quiet label instead of a menu, since "switch" among one option is just clutter.
Tenant-scoped tables (every Eloquent model with the trait):
workspace_id: agents, integration_connections, plan_subscriptions, usage_events, audit_logs, invitations.agent_versions, behavior_rules, cta_rules, curated_answers, experiments, sources, documents, chunks, visitors, conversations, messages, leads, content_gaps.Cross-workspace tables (no scope):
users — global. Membership in any workspace is in the pivot.workspaces — global. The workspace itself isn't scoped to a workspace.plans — global, platform-admin-managed.jobs / failed_jobs / notifications — Laravel infrastructure.app_settings — singleton, platform-wide.
Vector store metadata mirrors the tenancy contract — every point has
agent_id and workspace_id labels, and every
query filters by agent_id. Cloudflare Vectorize and Qdrant
both support this natively (Qdrant's payload-filter / Vectorize's
metadata-filter).
A bug that filtered by agent_id alone but not the agent's
actual workspace would still be safe — agent IDs are ULIDs, globally
unique. A bug that didn't filter at all would be a leak. The
Retriever always filters explicitly.
Tenancy is "is this row in my workspace?". Authorization is "what can
I do with rows in my workspace?". Policies live in
app/Policies/:
WorkspacePolicy — view/update/delete the workspace itself, transfer ownership.AgentPolicy — viewAny / view / create / update / delete / publish / rollback.SourcePolicy — manage sources within an agent.LeadPolicy — read / update / delete leads.IntegrationConnectionPolicy — connect / disconnect.
The check pattern is consistent: $user->can('update', $agent)
or abort_if(! $user->can(...)). Policies use the
Tenancy helper to resolve the user's role in the resource's
workspace and then call capability methods on the
WorkspaceRole enum.
The regression test is MultiTenancyTest under
tests/Feature/. It seeds two workspaces with overlapping
data and asserts that queries from each can only see their own rows.
It also walks every model in app/Models/ to confirm any
model with workspace_id uses the trait.
Run:
php artisan test --filter=MultiTenancyTest
Required to pass on every CI build. PRs that break it can't merge.