Multi-tenancy (opt-in)
O kit nasce single-tenant. Um comando liga o modo multi-tenant — e quem não precisa não paga nada por ele:
php artisan kit:tenancy # liga o modophp artisan kit:tenancy --demo # liga + cria um cenário de demonstraçãophp artisan kit:tenancy --force # confirma a recriação do banco sem perguntarO
--demotambém escreveKIT_DEMO=trueno.env. É essa chave que faz o resource de exemplo Projetos aparecer no/app— sem ela o painel de negócio continua vazio, que é o desenho do kit. Para tirar a demo da vista sem apagar nada,KIT_DEMO=false; para removê-la de vez, apague os arquivos que o comando lista ao final.
| Painel | Com o modo ligado |
|---|---|
| App | vira /app/{tenant}. O usuário só enxerga os tenants a que está vinculado, e ganha a administração da própria organização |
| Admin | ganha o cadastro de tenants e o vínculo de usuários — não é escopado, quem administra vê todos |
| Infra | inalterado: saúde, filas e logs são da instalação, não de um cliente |
Quem administra uma organização não administra a instalação
Os cinco papéis do kit, e o que cada um significa com o modo ligado:
| Papel | Painel | Contexto da atribuição | O que faz |
|---|---|---|---|
master_global |
todos | global | vence qualquer permissão, por Gate::before |
admin |
/admin |
global | usuários, papéis e permissões da instalação |
infra |
/infra |
global | saúde, filas, logs, auditoria, comandos |
admin_app |
/app |
a organização | usuários e convites da organização dele |
panel_user |
/app |
a organização | usa o negócio; não vê a administração |
admin_app é a persona que o modo multi-tenant cria: alguém que administra uma organização sem administrar o sistema. Dentro de /app/{slug} ele ganha Usuários e Convites, recortados àquela organização — e nada além disso. Ele não entra em /admin nem /infra, leva 404 no painel de outra organização, não alcança usuário de fora nem por URL direta, não cria nem edita papéis (só atribui, e só papéis do painel /app), não exclui usuário — o delete apagaria a pessoa de todas as organizações — e o convite que ele cria nasce carimbado com a organização dele, ignorando o formulário. E ele não vê nem edita quem governa a instalação: master_global, admin, infra ou qualquer papel de painel sem tenancy somem da lista, da busca e do badge, e a URL direta responde 404 — mesmo que a pessoa esteja vinculada à organização.
O papel só existe com a tenancy ligada, e a concessão é em /admin → organizações → Usuários vinculados → Papéis nesta organização. Não pelo cadastro do usuário: ali a atribuição vai para o contexto global e a pessoa entra no /app sem enxergar nada. A receita completa, com o sintoma, está em wikis/receitas.md.
O atalho para o painel de cada organização
As três telas de organização no /admin — listagem, ficha e edição — mostram o endereço do
painel de negócio daquela organização, clicável, abrindo em nova aba. Na listagem é uma
coluna, Painel, com o endereço visível: você sabe para onde vai antes de clicar, e comparar duas
organizações é abrir duas abas.
O endereço sai do gerador de URL do próprio painel, e não de /app/ + slug escrito à mão — então
ele acompanha o path do painel, o APP_URL da instalação e o atributo de slug configurado.
Trocar o slug de uma organização move o link na hora, o que é a outra metade do aviso que o campo
já dá: mudar invalida os links já compartilhados.
O link navega; ele não autoriza. É de propósito que ele apareça sempre, para todo mundo que consegue abrir a tela de administração — esconder o link esconderia também a informação de que a organização tem um painel. Quem seguir sem acesso recebe a recusa dos dois portões que já existem, e os códigos são diferentes:
| Situação de quem clica | Resposta | Quem barra |
|---|---|---|
Tem papel do /app e vínculo com a organização (ou é master_global) |
abre o painel | — |
Não tem papel do painel /app — o caso típico de quem só administra a instalação |
403 | canAccessPanel() |
Tem papel do /app mas não está vinculado a essa organização |
404 | canAccessTenant() |
O 404 do segundo caso é deliberado: um 403 confirmaria que a organização existe, e bastaria
varrer slugs para enumerar os clientes da instalação. Ele fica registrado no log do canal
tenancy, com o motivo sem_vinculo.
No cadastro de uma organização nova o link não aparece: enquanto o registro não está gravado, não existe endereço para apontar — e um link para um slug ainda não salvo seria 404 garantido.
Um seletor para quem não tem o que trocar
No topo da barra lateral do /app fica o seletor de organização — quem tem acesso a duas ou mais
troca de contexto por ele. Para quem tem acesso a uma só ele não tem utilidade: é só o nome da
organização repetido (o cabeçalho já o mostra). O interruptor Ocultar o seletor quando houver uma
organização só, na aba Aplicação das configurações, esconde o bloco inteiro nesse caso; com duas ou
mais organizações acessíveis o seletor continua lá.
A contagem é a mesma do próprio seletor: organizações ativas acessíveis — e, para o
master_global, todas as ativas. Esconder é decisão de aparência: /app/{tenant}, link direto e
canAccessTenant() não mudam. Nasce desligado — KIT_TENANCY_OCULTAR_SELETOR_UNICO=false no
.env.example — e vale no próximo F5, porque quem decide é uma avaliação por request
(App\Support\SeletorDeOrganizacao), não o boot do painel.

Insights das organizações no /admin
A listagem de organizações traz quatro widgets para operação global — a visão geral acima da tabela e os três de detalhe abaixo dela:
- visão geral com totais e organizações ativas;
- usuários únicos por organização na janela recente — organizações sem acesso elegível continuam visíveis com zero;
- acessos bem-sucedidos separados por painel;
- timeline das alterações recentes.
Ao abrir uma organização, o cabeçalho mostra seus usuários vinculados, usuários ativos e taxa de ativação, além dos acessos recentes dos membros. Os números respeitam soft deletes e contam apenas logins bem-sucedidos; o log de autenticação registra o painel de origem para sustentar o breakdown.
Código em inglês, interface no seu idioma
O código segue o vocabulário da API do Filament — model Tenant, tabela tenants, getTenants(), canAccessTenant() — para que a documentação oficial se leia sem tradução mental. O que o usuário vê é configurável, e nasce como “Organização”:
'tenancy' => [ 'label' => 'Empresa', // Organização · Cliente · Escola · Unidade · Loja 'label_plural' => 'Empresas', 'slug' => 'empresas', // /admin/empresas],As mesmas quatro entradas existem no .env, como semente e plano B: KIT_TENANCY (a flag, escrita
pelo kit:tenancy), KIT_TENANCY_LABEL, KIT_TENANCY_LABEL_PLURAL e KIT_TENANCY_SLUG.
Nas suas models
Toda model do negócio usa a trait do kit:
use App\Traits\BelongsToTenant;
class Projeto extends Model{ use BelongsToTenant;
protected $fillable = ['nome']; // `tenant_id` fora: a trait preenche}Ela dá a relação tenant(), um escopo global e o preenchimento automático de tenant_id. O escopo importa porque o Filament só recorta o que passa por um Resource — job, comando, listener e API ficariam de fora, e é aí que dado de um cliente vaza para outro.
⚠️
kit:tenancyrecria o banco. Ele ligapermission.teams, e a migration do spatie só cria as colunas de tenant se a flag estiver ativa antes do migrate. Por isso exige árvore git limpa, confirmação explícita e rodamigrate:fresh --seed. A hora de rodar é o dia 1 do projeto. O caminho detalhado — inclusive papéis globais × por tenant escopedUnique()— está emwikis/arquitetura.md.
Por dentro do kit:tenancy, e a requisição em /app/{tenant}
KitTenancy::handle() (app/Console/Commands/KitTenancy.php:handle:50) faz, nesta ordem:
pré-voo — git limpo (app/Console/Commands/KitTenancy.php:preVoo:103) — confirmação explícita, ou
--force (app/Console/Commands/KitTenancy.php:confirmarDestruicao:143), KIT_TENANCY=true no
.env (app/Console/Commands/KitTenancy.php:ligarFlagNoEnv:165,
app/Support/AtivadorDeTenancy.php:escreverEnv:35), permission.teams ligado por organização
(app/Console/Commands/KitTenancy.php:ligarPapeisPorTenant:172,
app/Support/AtivadorDeTenancy.php:ligarPapeisPorTenant:66), migrate:fresh --seed — destrutivo
— seguido da conferência do schema
(app/Console/Commands/KitTenancy.php:recriarBanco:179, :migrate:fresh:187,
:conferirSchema:189, definida em :198) e, só com --demo, o cenário de demonstração
(app/Console/Commands/KitTenancy.php:semearDemo:224).
Com o modo ligado, toda requisição a /app/{tenant} passa por dois middlewares do Filament antes de
chegar à tela: IdentifyTenant resolve o tenant da rota e já decide o 404 chamando
canAccessTenant() (vendor/filament/filament/src/Http/Middleware/IdentifyTenant.php:canAccessTenant:40,
app/Models/User.php:canAccessTenant:838); só depois disso o DefinirTenantDePermissoes — o
tenantMiddleware do kit — fixa o contexto de papéis por tenant
(app/Providers/Filament/AppPanelProvider.php:tenantMiddleware:622,
app/Http/Middleware/DefinirTenantDePermissoes.php:setPermissionsTeamId:46).
sequenceDiagram
%% DG-20
accTitle: Requisição em /app/{tenant}
accDescr: O IdentifyTenant resolve o tenant da rota e consulta canAccessTenant(); organização inativa, ou sem vínculo e não é master_global, responde 404, e só então o DefinirTenantDePermissoes fixa o contexto de papéis por tenant.
participant visitante as Visitante
participant identify_tenant as IdentifyTenant
participant can_access_tenant as canAccessTenant()
participant definir_tenant as DefinirTenantDePermissoes
Note over identify_tenant,definir_tenant: só existe com KIT_TENANCY=true (hasTenancy())
visitante->>identify_tenant: GET /app/{tenant}
identify_tenant->>can_access_tenant: organização ativa e vinculada?
alt organização inativa, ou sem vínculo e não é master_global
can_access_tenant-->>visitante: nega (404)
else acesso permitido
can_access_tenant-->>identify_tenant: permite
identify_tenant->>definir_tenant: Filament::setTenant()
definir_tenant-->>visitante: contexto de papéis fixado (team_id)
end
A organização inativa nega para todo mundo, inclusive o master_global — a checagem vem antes de
isMasterGlobal() (linha 813); só depois disso o master_global sempre entra (linha 826). É a
mesma assimetria corrigida que a tabela de 403×404 desta página já descreve.