Multi-tenancy (opt-in)
The kit is born single-tenant. One command turns multi-tenancy on — and those who don’t need it pay nothing for it:
php artisan kit:tenancy # turn it onphp artisan kit:tenancy --demo # turn it on + create a demo scenariophp artisan kit:tenancy --force # confirms the database recreation without asking
--demoalso writesKIT_DEMO=trueto.env. That key is what makes the sample Projetos resource show up on/app— without it the business panel stays empty, which is the kit’s design. To hide the demo without deleting anything,KIT_DEMO=false; to remove it for good, delete the files the command lists at the end.
| Panel | With the mode on |
|---|---|
| App | becomes /app/{tenant}. Users only see the tenants they’re linked to, and it gains the administration of their own organization |
| Admin | gains the tenant CRUD and the user linking — not scoped, whoever administers sees them all |
| Infra | unchanged: health, queues and logs belong to the installation, not to a client |
Administering one organization is not administering the installation
The kit’s five roles, and what each one means with the mode on:
| Role | Panel | Assignment context | What it does |
|---|---|---|---|
master_global |
all | global | beats any permission, via Gate::before |
admin |
/admin |
global | users, roles and permissions of the installation |
infra |
/infra |
global | health, queues, logs, auditing, commands |
admin_app |
/app |
the organization | users and invitations of their own organization |
panel_user |
/app |
the organization | uses the business; doesn’t see the administration |
admin_app is the persona multi-tenancy creates: someone who administers one organization without administering the system. Inside /app/{slug} they gain Users and Invitations, scoped to that organization — and nothing beyond that. They don’t enter /admin or /infra, get a 404 on another organization’s panel, can’t reach an outside user even by direct URL, don’t create or edit roles (they only assign, and only /app panel roles), don’t delete users — deleting would remove the person from every organization — and any invitation they create is stamped with their organization, ignoring the form. And they neither see nor edit whoever governs the installation: master_global, admin, infra or any role of a panel without tenancy vanish from the list, the search and the badge, and the direct URL answers 404 — even when that person is linked to the organization.
The role only exists with tenancy on, and it is granted in /admin → organizations → Linked users → Roles in this organization. Not from the user record: there the assignment goes to the global context and the person enters /app seeing nothing. The full recipe, with the symptom, is in wikis/receitas.md.
The shortcut to each organization’s panel
The three organization screens under /admin — list, record and edit — show that organization’s
business panel address, clickable, opening in a new tab. On the list it is a column,
Painel, with the address visible: you know where you are going before clicking, and comparing two
organizations is opening two tabs.
The address comes from the panel’s own URL generator, not from /app/ + slug written by hand — so
it follows the panel path, the installation’s APP_URL and the configured slug attribute.
Changing an organization’s slug moves the link right away, which is the other half of the warning
the field already gives: changing it invalidates the links already shared.
The link navigates; it does not authorize. It shows up always, on purpose, for everyone who can open the administration screen — hiding the link would also hide the fact that the organization has a panel. Whoever follows it without access gets the refusal of the two gates that already exist, and the codes are different:
| Situation of whoever clicks | Response | Who refuses |
|---|---|---|
Has an /app role and is linked to the organization (or is master_global) |
the panel opens | — |
Has no /app panel role — the typical case of someone who only administers the installation |
403 | canAccessPanel() |
Has an /app role but is not linked to that organization |
404 | canAccessTenant() |
The 404 of the second case is deliberate: a 403 would confirm the organization exists, and
sweeping slugs would be enough to enumerate the installation’s clients. It is recorded in the
tenancy log channel, with reason sem_vinculo.
On the create screen of a new organization the link is absent: while the record is not saved there is no address to point at — and a link to an unsaved slug would be a guaranteed 404.
A selector for people with nothing to switch to
At the top of the /app sidebar sits the organization selector — people who can reach two or more
use it to switch context. For someone with access to only one it is useless: just the
organization’s name repeated (the header already shows it). The Ocultar o seletor quando houver
uma organização só switch, on the Aplicação tab of the settings screen, hides the whole block in
that case; with two or more reachable organizations the selector stays.
The count is the selector’s own list: the user’s reachable active organizations — and, for
master_global, all of them. Hiding is a looks-only decision: /app/{tenant}, direct links and
canAccessTenant() do not change. It ships off — KIT_TENANCY_OCULTAR_SELETOR_UNICO=false in
.env.example — and takes effect on the next F5, because the decision is evaluated per request
(App\Support\SeletorDeOrganizacao), not when the panel boots.

Organization insights under /admin
The organization list includes four widgets for global operations — the overview above the table and the three detail widgets below it:
- an overview with totals and active organizations;
- unique users per organization in the recent window — organizations with no eligible access remain visible with zero;
- successful accesses broken down by panel;
- a timeline of recent changes.
Opening an organization shows its linked users, active users and activation rate, together with its members’ recent accesses. The numbers respect soft deletes and count successful logins only; the authentication log records the panel of origin to support the breakdown.
English in the code, your language in the UI
The code follows Filament’s API vocabulary — model Tenant, table tenants, getTenants(), canAccessTenant() — so the official docs read without mental translation. What the user sees is configurable, and defaults to “Organização”:
'tenancy' => [ 'label' => 'Company', // Organization · Client · School · Unit · Store 'label_plural' => 'Companies', 'slug' => 'companies', // /admin/companies],The same four entries exist in .env, as seed and fallback: KIT_TENANCY (the flag, written by
kit:tenancy), KIT_TENANCY_LABEL, KIT_TENANCY_LABEL_PLURAL and KIT_TENANCY_SLUG.
In your models
Every business model uses the kit’s trait:
use App\Traits\BelongsToTenant;
class Projeto extends Model{ use BelongsToTenant;
protected $fillable = ['nome']; // `tenant_id` stays out: the trait fills it}It provides the tenant() relationship, a global scope and automatic tenant_id filling. The scope matters because Filament only scopes what goes through a Resource — jobs, commands, listeners and APIs would be left out, and that’s exactly where one client’s data leaks into another’s.
⚠️
kit:tenancyrecreates the database. It turns onpermission.teams, and the spatie migration only creates the tenant columns if the flag is active before the migrate. That’s why it requires a clean git tree, an explicit confirmation, and runsmigrate:fresh --seed. The time to run it is day 1 of the project. The detailed path — including global vs. per-tenant roles andscopedUnique()— is inwikis/arquitetura.md(pt-BR).
Inside kit:tenancy, and the request to /app/{tenant}
KitTenancy::handle() (app/Console/Commands/KitTenancy.php:handle:50) runs, in this order:
pre-flight — clean git (app/Console/Commands/KitTenancy.php:preVoo:103) — explicit confirmation,
or --force (app/Console/Commands/KitTenancy.php:confirmarDestruicao:143), KIT_TENANCY=true in
.env (app/Console/Commands/KitTenancy.php:ligarFlagNoEnv:165,
app/Support/AtivadorDeTenancy.php:escreverEnv:35), permission.teams turned on per tenant
(app/Console/Commands/KitTenancy.php:ligarPapeisPorTenant:172,
app/Support/AtivadorDeTenancy.php:ligarPapeisPorTenant:66), migrate:fresh --seed —
destructive — followed by a schema check
(app/Console/Commands/KitTenancy.php:recriarBanco:179, :migrate:fresh:187,
:conferirSchema:189, defined at :198) and, only with --demo, the demo scenario
(app/Console/Commands/KitTenancy.php:semearDemo:224).
With the mode on, every request to /app/{tenant} goes through two Filament middleware before
reaching the screen: IdentifyTenant resolves the tenant from the route and already decides the
404 by calling canAccessTenant()
(vendor/filament/filament/src/Http/Middleware/IdentifyTenant.php:canAccessTenant:40,
app/Models/User.php:canAccessTenant:838); only after that does DefinirTenantDePermissoes — the
kit’s tenantMiddleware — fix the per-tenant role context
(app/Providers/Filament/AppPanelProvider.php:tenantMiddleware:622,
app/Http/Middleware/DefinirTenantDePermissoes.php:setPermissionsTeamId:46).
sequenceDiagram
%% DG-20
accTitle: Request to /app/{tenant}
accDescr: IdentifyTenant resolves the tenant from the route and calls canAccessTenant(); an inactive organization, or one with no link and not master_global, answers 404, and only then does DefinirTenantDePermissoes fix the per-tenant role context.
participant visitante as Visitor
participant identify_tenant as IdentifyTenant
participant can_access_tenant as canAccessTenant()
participant definir_tenant as DefinirTenantDePermissoes
Note over identify_tenant,definir_tenant: only exists with KIT_TENANCY=true (hasTenancy())
visitante->>identify_tenant: GET /app/{tenant}
identify_tenant->>can_access_tenant: is the organization active and linked?
alt inactive organization, or no link and not master_global
can_access_tenant-->>visitante: refuses (404)
else access allowed
can_access_tenant-->>identify_tenant: allows
identify_tenant->>definir_tenant: Filament::setTenant()
definir_tenant-->>visitante: role context fixed (team_id)
end
An inactive organization refuses everyone, including master_global — the check runs before
isMasterGlobal() (line 813); only after that does master_global always get in (line 826). It is
the same fixed asymmetry that this page’s 403×404 table already describes.