Motivation
A team is a tenant: atenant_id slug that scopes
every Eloquent query (R30/R31). The team switcher lets a user
move between the teams they already belong to, and reads each team’s display
name from the tenants registry row. But two operator questions were, until
now, answerable only from the CLI:
- How do I create a new team from the app? The only path was
php artisan company:create, which also mints a brand-new admin user and is deliberately off the HTTP surface. - How do I rename a team? The only HTTP path lived inside the AI-Act
compliance package (
PATCH /api/admin/ai-act-compliance/tenants/{slug}), gated by that package’sviewAiActCompliance— the wrong home for general team governance.
What a “team” is here
There is no hostTeam model and no host teams table. A team is the
tenant_id string stamped on every tenant-aware row; its editable display
name lives on an optional vendor row — the tenants table shipped by
padosoft/laravel-ai-act-compliance (Padosoft\AiActCompliance\MultiTenancy\Models\Tenant).
That is the very table UserTeamsResolver reads for switcher
labels, so team management writes to exactly the source the switcher displays.
The default team is the host’s “no multi-tenancy” sentinel: it has no
tenants row and is reserved — never creatable, never renamable.
Design
The piecesData model
The team’s editable name is thename column of the vendor tenants table:
Creating a team also writes a
projects row (tenant_id=slug, an initial
project_key) and a project_memberships row for the acting user — the same
ordering company:create uses, so the new team is immediately usable and appears
in the creator’s switcher.
Decision rationale (ADR-style)
- Build over the vendor registry, not a new host table. The switcher already
depends on
tenantsfor names; a second host-owned name table would fork the source of truth. When the table is absent the feature degrades (below) rather than duplicating it. Thetenantstable is a registry, modelled likeUser— it is intentionally not tenant-aware and is excluded fromTenantIdMandatoryTest(R30/R31), because itsslugis the tenant id every other table references. - Rename authorizes the target team, not the request scope. Team
management is cross-tenant by nature (you administer other teams from within
your active one), so the request’s
X-Tenant-Iddoes not authorize a rename. The service independently requires a membership in the target team or thetenant.cross-accesspermission — the exact ruleAuthorizeTenantHeaderenforces on the switcher path — and a team you may not administer returns 404 (IDOR-safe: existence stays hidden). - No MCP write tool — a deliberate R44 exception. Like the
project registry, team management is an admin
governance affordance, not an agent capability: a
tenant_idis already usable across every surface without a registry row, and a cross-tenant write tool conflicts with the propose-only MCP posture. It ships HTTP + CLI only; the omission is documented in the controller and here rather than left implicit. defaultis reserved. It has notenantsrow and is the host fallback scope; renaming or re-creating it would be meaningless (and would collide with the switcher’sdefault-sentinel handling), so both are rejected.
Worked example
Anadmin who is a member of acme opens Administration → Teams:
GET /api/admin/teamsreturnsacme(manageable) and the read-onlydefault. A super-admin would additionally see every active team viatenant.cross-access.- Create → name
Globex Corp. The slug auto-fills toglobex-corp(editable, immutable after).POST /api/admin/teamswrites thetenantsrow + an initialglobex-corpproject + a membership for the admin, atomically. - The page refetches
/api/auth/me; the topbar switcher now lists Globex Corp, switchable immediately. - Rename
Globex Corp→Globex Corporation:PATCH /api/admin/teams/globex-corpupdatestenants.name; the switcher label updates without a reload. - A rename of a team the admin neither belongs to nor has cross-access for → the API answers 404, and the row exposes no Rename action in the first place.
Gotchas
- The registry table is optional — the feature degrades, it never 500s. When
Schema::hasTable('tenants')is false (the AI-Act package was never migrated), create/rename raiseTeamRegistryUnavailableException, which the controller maps to a clean 503 witherror: team_registry_unavailable(R14/R43). The list still renders with humanised slug names. - The slug is immutable. It is the
tenant_idevery document, chunk, membership and chat log joins to — renaming changes only the displayname. To “rename the id”, create a new team and migrate content. - A normal admin only sees their own teams. Cross-team visibility requires
tenant.cross-access(super-admin). This is why create attaches the acting user as a member — otherwise an admin could create a team they then couldn’t see.