Qualidade de código
PHPStan no level 8 — e por que isso é um ponto forte
A maioria dos projetos Laravel para no level 5 ou 6. O kit roda no 8, com zero erros e sem
baseline: não há @phpstan-ignore espalhado, não há phpstan-baseline.neon escondendo dívida.
O que o 7 e o 8 pegam e o 6 não pega, na prática:
- Nulo não checado (o que o 8 acrescenta).
Filament::getCurrentPanel()devolve?Panel;auth()->user()devolve?User;Panel::getLoginUrl()devolve?string. Até o level 7 você chama método neles e passa. No 8, precisa provar que existe. - Tipo largo do vendor entrando no seu código.
session()émixed,env()ébool|string, os getters do Shield são?array. O 7 obriga a estreitar na fronteira, uma vez, em vez de torcer para o valor ser o esperado em cada uso. list<T>vsarray<int,T>.filter()emap()preservam chave. Um array com buracos entregue onde se esperava lista é bug que só aparece nojson_encode— vira objeto em vez de array, e o front quebra.
Subir de 6 para 7 expôs 29 erros reais no kit, e um deles era bug latente de verdade: um
Convite|null com método chamado direto. Subir de 7 para 8 expôs mais 48, todos de nulidade:
27 eram nulo impossível escrito com um tipo largo demais, 7 eram URL de painel que é null em
painel sem ->login() — e ganharam o destino que o kit já usa, a raiz do painel —, 13 eram
invariante sem guarda, e 1 era anotação errada do Laravel, contornada por guarda de invariante no
middleware do kit. Todos corrigidos na origem — nenhum silenciado.
⚠️ Ponto de atenção ao implementar no seu projeto
O level 8 vale para o código que você escrever também.
composer testrodaphpstan analysee reprova o build inteiro.O que mais aparece quando alguém começa a escrever no kit:
Você escreve O que o PHPStan cobra auth()->user()->idprove que há usuário: auth()->user()?->id, ou umifantesFilament::getTenant()->nome?Model— useinstanceof Tenantcomo guarda->filter()->all()num@return list<string>array_values()no fimenv('ALGUMA_COISA')direto numstr_*(string) env(...), ouconfig()com default tipadométodo sem tipo de retorno declare o tipo; o kit exige em tudo Não resolva com
@phpstan-ignorenem baseline. O kit tem exatamente três exceções emphpstan.neon: um macro de vendor resolvido em runtime (simpleLightbox()), um ponto de extensão sem uso dentro do kit (WidgetDinamico) e a anotação insatisfazível decustomMyProfilePage()do filament-breezy — cada uma com escopo de arquivo, o motivo, as alternativas testadas e descartadas, e o teste que cobre o ponto de verdade.tests/Kit/QualidadeDeCodigoTest.phptrava o inventário: a subida para o level 8 não acrescentou nenhuma. Esse é o padrão: se precisar de exceção, ela vem com a justificativa e com o teste que a substitui.Macro de componente do Filament não vira exceção: o
happenv-com/filament-phpstan-macros(incluído nophpstan.neon) lê os macros registrados em service provider e os tipa como método — é por isso que$table->infiniteScroll()é analisado semhasMacro()nemignoreErrors. OsimpleLightbox()continua como exceção porque é registrado noboot(Panel)do plugin, que não roda na análise.Se quiser afrouxar no seu projeto, é uma linha em
phpstan.neon. Mas saiba o que está trocando: os 77 erros acima eram todos reais.
FilaCheck: o lint que só entende de Filament
composer filament:check roda o laraveldaily/filacheck — 17 regras que o Pint e o PHPStan não
têm como ter: método depreciado da API do Filament, namespace errado de action, chamada que mudou
entre versões. Ele entra no composer test junto com o pint e o phpstan, então a CI reprova o
mesmo que a sua máquina.
Ao ser adotado, ele encontrou 7 problemas preexistentes no próprio kit — seis métodos de teste
depreciados e um ImageColumn::size() — todos corrigidos.
Rector: upgrade de major, não lint
O kit tem quatro ferramentas de qualidade, em quatro eixos — e só três estão no gate:
| Ferramenta | Eixo | Ao achar problema | Roda |
|---|---|---|---|
| Pint | estilo | corrige | sempre (gate) |
| PHPStan + larastan | tipos | reporta | sempre (gate), level 8 |
| FilaCheck | API do Filament | reporta | sempre (gate) |
| Rector | reescrita de código | muda semântica | sob demanda |
composer refactor:preview e composer refactor:apply não estão no composer test — e isso é
deliberado.
Para que o Rector serve aqui: upgrade de major. Laravel 13 → 14, PHP 8.4 → 8.5. O rector.php
da raiz nasce sem nenhum set ligado, e traz, num bloco de comentário, qual set ligar em cada
caso. O fluxo é: descomentar o set → composer refactor:preview → ler o diff inteiro →
composer refactor:apply → composer test → desligar o set de novo.
Por que ele fica fora do gate — foi medido, não opinado. Com os sets de qualidade do Laravel ligados, o Rector reescreveria 103 arquivos deste projeto. Os três maiores motivos:
| Regra | Arquivos | O que propõe |
|---|---|---|
EloquentMagicMethodToQueryBuilderRector |
35 | User::find() → User::query()->find() |
AddClosureVoidReturnTypeWhereNoReturnRector |
26 | : void em closure |
AppToResolveRector |
21 | app() → resolve() |
São opinião de estilo, não correção. Num kit cujo produto é o código-exemplo legível,
User::find() e app() são o idioma que o ecossistema lê sem parar.
E há um caso que fecha a questão. CarbonToDateFacadeRector propõe, no InfraPanelProvider:
Carbon::now()->subDays(...)Date::now()->subDays(...)E isso quebra, por três fatos verificáveis:
now()éDate::now()—Illuminate/Foundation/helpers.php:623- O kit faz
Date::use(CarbonImmutable::class)—KitServiceProvider.php:57 FilamentExceptionsPlugin::modelPruneInterval()exigeCarbonmutável
O PHPStan level 7 já reportou exatamente esse erro quando o código usava now(). O
Carbon::now() explícito é a correção — e o Rector a desfaria.
Ferramenta de qualidade que reverte a correção de outra não é gate, é disputa — e o build passaria a depender de qual das duas rodou por último.
tests/Kit/QualidadeDeCodigoTest.php fixa isso: falha se o Rector entrar no composer test, ou se
um set de qualidade for ligado.
Upgrade de Filament é outra ferramenta. Não existe regra de Filament no
driftingly/rector-laravel — busca por “filament” no pacote devolve zero. Não é lacuna: o
Filament distribui a própria ferramenta, também baseada em Rector.
composer upgrade:filament # roda o vendor/bin/filament-v5 — o filament/upgrade já está no require-devEla é mantida em lockstep com o framework — quem escreve as regras é quem quebra a API.
A leitura completa das quatro ferramentas está em
wikis/qualidade-de-codigo.md.
Os testes do kit
O kit traz sua própria suíte, isolada em tests/Kit/ — acesso aos três painéis, telas de infra e admin de pé, invariantes da fundação (uuid, gates, auditoria) e o contrato da camada de IA.
Ela fica separada da sua de propósito: depois de um kit:update você quer saber se a fundação continua íntegra, sem esperar a suíte do seu negócio.
composer test:kit # em paralelo — ~3 mincomposer test:kit:serial # em série, para investigar falhaphp artisan test --testsuite=Feature # só os SEUS testesRoda em paralelo por padrão. Medido nesta suíte: 12m26s → ~3min (20 núcleos), mesmos casos e
mesmas asserções. Cada worker tem o próprio banco, porque o phpunit.xml usa SQLite :memory:, que
é por processo.
Se uma falha aparecer só em paralelo, é sinal de teste que depende de ordem ou de estado
compartilhado — composer test:kit:serial isola isso, e a diferença entre os dois é o diagnóstico.
Por que
--testsuitee não--group=kit: opest-plugin-browsersobe o Playwright já na coleta, ao parsear qualquer arquivo comvisit()— antes de qualquer filtro de grupo ser consultado. Num projeto recém-instalado, sem os browsers baixados,--group=kitmorre emPlaywrightNotInstalledExceptionsem rodar um único teste.
Argumento extra precisa de
--:composer test:kit --parallelé engolido em silêncio pelo Composer; o que funciona écomposer test:kit -- --parallel. Como o paralelo já é o padrão, você não precisa disso — mas vale saber para qualquer outra flag.
Seus testes vão em tests/Feature e tests/Unit, como de costume — o kit não encosta neles.
A cobertura de testes — e o que o número não inclui
86 % das linhas de app/ — 9.866 de 11.366 statements (86,80%), medido em 2026-10-23, no job cobertura da main. O badge do
README vem daqui, e o CI reprova quando ele mente.
O número varia um pouco com o sistema operacional, e o badge foi desenhado para absorver isso: numa medição de 2026-09-25, a mesma árvore deu 79,89 % no Windows e 79,82 % no Linux — 7 statements de diferença, de código que só roda numa das plataformas. Os dois truncam para
79%, então o badge confere nos dois. Se ele guardasse a casa decimal, o CI reprovaria toda medição feita na outra plataforma.
Os números com casa decimal desta página são datados, não derivados: eles valem para a medição de 2026-09-26 e envelhecem. O único que tem guarda automática é o percentual inteiro, travado contra
.github/badges/cobertura.jsonpelo[CT-49].
composer test:coverage # mede, grava o badge e aplica o piso — ~32 min, em série
# as duas metades, separadas, quando você já tem o Clover:php -d pcov.enabled=1 vendor/bin/pest --testsuite=Kit,Tenancy --no-tia --coverage-clover=cobertura.xmlphp artisan kit:cobertura cobertura.xml --min=78 # confere (é o que o CI faz)php artisan kit:cobertura cobertura.xml --min=78 --write # grava o badgeO kit:cobertura viaja com o kit: no seu projeto ele aplica o piso que você escolher, sobre o
phpunit.xml que você tiver. A parte do badge é a única que é só do kit — fora da árvore dele o
.github/badges/ não existe, e o comando simplesmente não mexe em badge nenhum.
Os três badges de teste do README, e o que cada um garante
| Badge | De onde sai | Quem impede de envelhecer |
|---|---|---|
| cobertura | .github/badges/cobertura.json, gerado por composer test:coverage |
o job cobertura do CI, que remede e reprova se o JSON mentir |
| casos de teste | contagem de blocos it()/test() em tests/ |
o [CT-50], que recalcula da árvore a cada rodada da suíte |
| PHPStan | o level: do phpstan.neon |
o [CT-50], pelo mesmo mecanismo |
Os dois últimos não dependem de rodar nada — são deriváveis por leitura, então a guarda calcula a verdade sozinha em milissegundos. O de cobertura depende dos ~32 min de medição local (52 min no runner do CI), e por isso vive num arquivo versionado com conferência no CI.
O badge de casos conta blocos escritos, não casos executados. Um
it()com->with()de cinco linhas vira cinco casos na execução — por isso o número do badge é menor que o que o Pest imprime. É a grandeza que dá para derivar sem rodar a suíte, e é o que o badge quer dizer: quantos cenários foram escritos à mão.
Por que em série, e por que demora
--parallel --coverage não existe: o Pest imprime o usage do paratest, e
artisan test --parallel --coverage-clover roda e não gera arquivo nenhum. A medição é serial por
construção, e o custo foi medido dos dois lados:
| Onde | Em paralelo, sem cobertura | Em série, com cobertura |
|---|---|---|
| máquina local, 20 núcleos | ~3 min | 32 min |
| runner do CI, 4 vCPU | ~6 min | 52 min |
Por isso o job roda em main e por disparo manual, não em toda PR: 52 minutos é o número que
importa, porque é onde a conta é paga, e cobrar isso de toda PR multiplicaria por nove o tempo de
CI do repositório.
O driver é o PCOV, não o Xdebug: para cobertura de linha o Xdebug não acrescenta nada e
custa muito mais. Ele fica desligado no php.ini (pcov.enabled=0) e liga só na invocação, para
que a suíte do dia a dia não pague os +44 % de instrumentação.
O número é um piso, não um teto
Três coisas ficam fora da conta, e ler o número sem saber disso leva à conclusão errada:
| Fora da medição | Por quê |
|---|---|
tests/Browser e tests/BrowserTenancy |
rodam contra um servidor em outro processo; o PCOV instrumenta o processo do Pest |
| Os testes de instalação | rodam composer create-project de verdade, também em outro processo |
tests/Unit e tests/Feature |
são seus, não do kit — têm um arquivo de exemplo cada |
O efeito é visível na decomposição: app/Console aparece a 34 % e é, na prática, o código mais
exercitado do kit — os testes de instalação executam kit:install inteiro, de fora. Lê-lo como
“os comandos não têm teste” seria exatamente o erro que esta seção existe para evitar.
A lacuna que esta página apontava em 2026-09-25 — app/Policies, a 23 % — fechou: em
2026-09-26 as 16 policies estão a 100 % (222 de 222 statements), depois dos cenários que o
quality gate pediu. O que continua verdadeiro: autorização é onde defeito silencioso custa mais
caro, e o mutation score delas mede pouco por construção — ver a seção do mutation score.
O piso é 78 %, e ele não é folclore
Com 10.003 statements, 1 ponto percentual vale ~100 linhas. Um bug corrigido, um método novo ou um refactor não movem o número; para cair os 4,12 pp de folga é preciso que mais de 410 statements entrem sem teste — que é o único evento que o piso existe para pegar.
E cobertura de linha não mede se a suíte detecta defeito: ela mede o que foi executado, não
o que foi verificado. Quem responde isso é o mutation score, e para ele o kit usa
pest --mutate (em Windows, pelo pestw.cmd da raiz — o cabeçalho do arquivo explica por quê).
O mutation score, e por que ele responde o que a cobertura não responde
Cobertura mede o que foi executado. Mutation testing mede o que foi verificado: ele altera o
código de propósito — troca um < por <=, apaga uma chamada, inverte um && — e pergunta se
algum teste fica vermelho. Mutante que sobrevive é um defeito que ninguém detectaria.
# Linux e macOSvendor/bin/pest tests/Kit/AlgumTest.php --mutate --path=app/Support/X.php --covered-only --no-tia
# Windows — pelo lançador da raiz, e o motivo está no cabeçalho delecmd /c pestw.cmd tests/Kit/AlgumTest.php --mutate --path=app/Support/X.php --covered-only --no-tiaMedições desta árvore, com o comando, a duração e a plataforma — sem os três, um score não é auditável:
| Alvo | Mutantes | Score | Duração | Plataforma |
|---|---|---|---|---|
app/Support/CustomizadorDaInstalacao.php |
225, sendo 4 sobreviventes | 98,22 % | 42,96 s | Windows, PHP 8.4.25, PCOV |
app/Console/Commands/KitCobertura.php |
158, sendo 0 sobreviventes | 100 % | 31,25 s | Windows, PHP 8.4.25, PCOV |
app/Policies/ (as 16) |
4 | 100 % | 0,19 s | Windows, PHP 8.4.25, PCOV |
Leia UNTESTED como sobrevivente. No pest-plugin-mutate, UNTESTED é o mutante com o qual o
processo de teste passou — o defeito que escaparia
(vendor/pestphp/pest-plugin-mutate/src/MutationTest.php:hasFinished:120, que emite
mutationEscaped). Mutante em linha que nenhum teste executa é outra categoria, UNCOVERED, e o
--covered-only a tira da conta. Os sobreviventes das medições acima, nomeados:
app/Support/CustomizadorDaInstalacao.php:524— quatro mutações no default deconfig('kit.tenancy.label_plural', …), que nunca é lido: a chave sempre existe emconfig/kit.php. São equivalentes na configuração do kit, e declarados assim — um teste que removesse a chave teria de afirmar como esperado o plural errado que o próprio código chama de defeito
O KitCobertura saiu de 89,24 % (17 sobreviventes, medido em 2026-09-25) para 100 % em
2026-09-26. Os quatro últimos sobreviventes (:57, :59, :198) eram saídas antecipadas: sem a
dica ou sem o return, a execução seguia e imprimia também a mensagem do caso seguinte, e os
testes afirmavam a primeira mensagem sem afirmar a ausência da segunda.
Score alto e instantâneo é sintoma, não resultado. No Windows, rodar por
vendor/bin/pestem vez do lançador devolve 100 % em segundos para uma suíte de minutos — o plugin relançaargv[0], ocmdnão executa um scriptsh, e toda saída não-zero é contada como mutante morto. A linha das policies acima é honesta por outro motivo: 4 mutantes para 222 statements, porque código de pura delegação não tem operador para mutar. Ali o instrumento não é a mutação — é a asserção de que cada policy confere a permissão do próprio modelo.
Os níveis de qualidade que o kit não adotou, e por quê
Levantamento de 2026-09-25. Cada candidato foi rodado contra esta arvore antes de entrar na tabela — porque toda ferramenta de qualidade parece boa no abstrato, e o que separa as que valem é quanto ruído elas produzem neste código. Um analisador que acusa 594 vezes não é mais rigoroso que um que acusa 48; é um que ninguém vai ligar.
| Nível | Medido aqui | Veredito |
|---|---|---|
arch()->preset()->php() |
passa limpo, 53 asserções, 6,5 s | adotado |
arch()->preset()->security() |
1 classe acusada (3 exec() no KitInstall, com constante) |
adotado, com a exceção declarada e confinada |
| PHPStan level 8 | 48 erros em 2026-09-25, 0 em 2026-09-26 | adotado — é o gate atual (seção do topo desta página) |
PHPStan level 9 / max |
474 e 594 erros | não — é precipício, não degrau |
arch()->preset()->laravel() |
reprova na 1.ª classe (controller com método fora do conjunto REST) | não — a convenção do kit é deliberada |
arch()->preset()->strict() |
163 de 222 classes não são final |
não — AgenteBase existe para ser estendida |
arch()->preset()->relaxed() |
proíbe método private; 54 arquivos usam |
não — contraria o estilo do kit |
declare(strict_types=1) |
98 de 240 arquivos | roadmap — não é falta de rigor, é inconsistência, e exige decidir o lado |
composer-require-checker + composer-unused |
não instalados | roadmap, prioridade alta — respondem “das 58 dependências, quais são usadas?” |
| Branch coverage (Xdebug) | Xdebug 3.5.3 instalado | roadmap — é a única razão de o Xdebug existir aqui |
pest-plugin-type-coverage |
não instalado | roadmap |
| Deptrac | não instalado | não — o kit não tem fronteira de camada a defender |
Os três critérios de corte, aplicados a cada linha: (a) responde a uma lacuna já medida neste projeto? (b) o custo de adoção é conhecido? (c) o que ele pega não é pego por nada que já existe? Os itens de roadmap passaram em (a) e (c) e esperam orçamento; os recusados falharam em (a) ou conflitam com convenção escrita.
Os números acima envelhecem —
474no level 9 é de 2026-09-25 e muda a cada release. Quem for executar um item de roadmap remede antes de estimar: o48do level 8 foi remedido antes de ser executado, e não tinha mudado.
As imagens do README saem de um teste
As capturas de tela deste README não são feitas à mão. Elas nascem de
tests/BrowserTenancy/CapturaDeArteTest.php, na mesma suíte que prova que as telas funcionam:
composer artO comando navega de verdade, salva os PNG, publica em art/, gera as thumbs de art/thumbs/ e
monta o GIF do fluxo. É o único jeito que encontramos de a documentação não envelhecer: ninguém
refaz quinze imagens a cada release, e o resultado é um README mostrando uma versão do kit que
não existe mais.
| Etapa | O que faz |
|---|---|
npm run build + view:cache |
pré-requisitos duros da suíte de navegador |
KIT_ART=1 pest tests/BrowserTenancy/CapturaDeArteTest.php |
navega e escreve os PNG em tests/Browser/Screenshots/ (caminho fixo do plugin) |
php artisan kit:arte |
copia para art/, redimensiona as thumbs e monta o GIF |
Três decisões que valem saber antes de mexer:
KIT_ART=1não é enfeite. É variável só de teste — não existe emconfig/nem no.env.example; o próprio arquivo de teste a lê. Sem a variável o arquivo é skipped. Ele escreve emart/, e uma suíte de CI que suja a árvore de trabalho é pior que uma suíte lenta.- As medidas são fixas: 1400x875 no cheio, 760x475 na thumb. É a proporção das imagens que já
estavam no
art/, e a galeria põe duas thumbs por linha — thumb com outra proporção desalinha a tabela. - O GIF é slideshow, montado com
ffmpega partir de três quadros. O plugin de navegador não grava vídeo, e quadro capturado é o que dá para reproduzir de forma determinística. Semffmpegno PATH o comando avisa e segue: as imagens estáticas já foram publicadas.
Precisa só refazer as thumbs, sem repetir a navegação? php artisan kit:arte --sem-gif.
Como os testes são pensados: varredura SFDIPOT
Toda feature nova passa por uma varredura SFDIPOT antes de virar caso de teste. A heurística, criada por James Bach, divide o sistema em sete perspectivas para que nenhuma dimensão seja esquecida na especificação:
| Letra | Perspectiva | O que cobre |
|---|---|---|
| S — Structure | Estrutura | Código, arquivos, componentes físicos ou lógicos |
| F — Function | Função | O que o software faz, suas funcionalidades |
| D — Data | Dados | O que o sistema processa, armazena ou manipula |
| I — Interfaces | Interfaces | Telas, APIs, integrações, entradas e saídas |
| P — Platform | Plataforma | Sistema operacional, hardware ou ambiente onde roda |
| O — Operations | Operações | Como o usuário ou administrador usa o sistema no dia a dia |
| T — Time | Tempo | Concorrência, desempenho, histórico ou a sequência dos eventos |
O benefício está em não derivar os testes só do “caminho feliz”. O que escapa raramente é mais um caso a mais — geralmente é uma dimensão inteira (dados, plataforma, tempo, operações) que ninguém lembrou de cobrir. A varredura força essa revisão no plano, antes do código existir.