Tenants
Multi-tenancy, set up once: every tenant-scoped call runs inside one team. The URL names the team, the package checks the person belongs, and $context->find() finds only that team's rows.
- POST teams/acme/actions/update-postthe team is in the URL
- Find the teamby the model's route key: a slug or a UUIDno such team: 404
- Tenant membershipyour tenant.membership classnot a member: 404
- authorize(), validationyour own rulesdenied: 403
- $context->find()finds the post in Acme onlyother team's post: 404
Turn it on
Set tenant.model, tenant.parameter (the route segment, team), tenant.membership, a class that answers whether a person may enter a tenant, and tenant.scope, a class that narrows find() to it. Each class is a few lines: Membership and scope classes shows both, the rules they follow and the closure forms. The model sets its own route key, such as a slug, and the prefix names the plain parameter: what your tenant model needs.
From then on every action is tenant-scoped. An action that belongs to the account rather than a team, such as editing a profile, sets $tenantScoped = false. Mount the two kinds in two groups:
Route::middleware('auth')
->prefix('teams/{team}')
->name('teams.')
->group(fn () => Actions::routes(tenant: true));
Route::middleware('auth')->group(fn () => Actions::routes(tenant: false));Actions::routes() with no argument mounts every action, so once tenants are on, mount these two instead. Token clients get the same pair in routes/api.php: see Mounting the routes.
A stranger sees a 404
- Sam
- Lee
- Kim
- Ari
- POST teams/acme/actions/update-postruns as Sam, inside Acmeruns
- POST teams/globex/actions/update-postSam is not a member404
- POST teams/no-such-team/actions/update-postno team has this slug404
The package checks membership itself, on every surface, before your code reads the tenant. Keep that by leaving membership to the package: a group whose own middleware checks it first answers with that middleware's response instead, often a 403. See When your own middleware checks membership first, which also covers a starter kit with teams.
find() stays inside the team
$context->find(Post::class, $id) looks the row up through your tenant.scope, so another team's post is simply not found. Your scope decides which rows are the team's: its conditions stay in one group, and find()'s key only narrows them, so an orWhere you add to the scope, such as shared posts, makes those rows reachable from every team, in Write actions too. Your own queries, such as Post::query() in handle(), are yours to scope. See Security for the guarantees.
Tokens and MCP
- POST mcp/t/acmeAcme's tenant pathAcme's tools
- POST mcp/t/globexGlobex's tenant pathno tools
- POST mcp/actionsthe base pathno tools
A token binds to a tenant with the ability tenant:{key}, by primary key. Over MCP, tenant-scoped actions live on the tenant path, such as mcp/t/{team}, and the rest on the base path, so a client that needs both connects both URLs. See Tenants in the MCP guide.
The copilot, the queue and the feed
- One conversation per team.
Actions::conversation($agent, $user, $team)keeps one copilot conversation per person, tenant and agent. See One conversation per tenant. - Queued runs keep the tenant. A dispatched action carries its person and tenant, and the worker checks membership again. See Queued runs.
- The change feed is per tenant. A write in a team reaches every member's open page within one poll. See The change feed.