Os quatro estados num relance
Toda linha de plugin carrega uma coluna status com um de dois valores — active ou inactive. Mais dois estados são implícitos: ainda não registrado (sem linha no DB, sem entrada no master-file) e deletado (diretório de arquivos sumiu, linha sumiu, entrada do master-file sumiu). Quatro transições movem o plugin entre eles:
| Transição | Método | Status antes | Status depois | O que muda no disco / DB |
| Register | Plugin::register($name) | (sem linha) | inactive | Linha do DB inserida; entrada do master-file escrita; service provider bootado na request atual |
| Activate | $plugin->activate() | inactive | active | Migrations rodam via o activate hook; status do DB flipado; error do master-file limpo |
| Disable | $plugin->disable() | active | inactive | Status do DB flipado; error do master-file limpo. Nada mais. |
| Delete | $plugin->deleteAndCleanup($keepData = false) | qualquer | (sem linha) | Delete hook dispara (tipicamente migrate:rollback); pasta do plugin removida; linha do DB removida; entrada do master-file removida |
O modelo mental que vale segurar: register e delete mudam o mundo (arquivos no disco, schema do DB). Activate e disable só flipam uma flag — as routes, views, hooks e estado do service provider do register ficam no lugar. As próximas seções cobrem cada transição em ordem.
Estado 1 — Register / install
Plugin::register($name) em app/Model/Plugin.php:559 é o ponto de entrada. É chamado automaticamente no fim de php artisan plugin:init e em todo upload bem-sucedido pela página admin Plugins. O método faz cinco coisas distintas em ordem:
- Lê o
composer.json de storage/app/plugins/{vendor}/{name}/ e copia title, description, version para o model. Lança se o campo name do composer não bate exatamente com o diretório.
- Insere (ou atualiza) a linha na tabela DB
plugins com status = inactive. firstOrNew(['name' => $name]) é o lookup, então re-registrar um plugin existente atualiza em vez de duplicar.
- Escreve o master file:
storage/app/plugins/index.json recebe uma entrada { "name": { "status": "inactive" } }. Esse é o registry em tempo de boot que o host lê em toda request sem ir ao DB.
- Carrega o service provider imediatamente:
$plugin->load($withServiceProvider = true) registra o prefixo PSR-4 com um Composer\Autoload\ClassLoader fresco e chama App::register() na classe do service provider do plugin. Quando o método retorna, as routes, views e hooks do plugin estão conectados no processo rodando.
- Materializa traduções e publica assets:
Language::dump() cria arquivos runtime per-locale sob storage/app/data/plugins/{vendor}/{name}/lang/, depois artisan vendor:publish --force --tag=plugin copia qualquer asset empacotado para public/plugins/{vendor}/{name}/.
Depois do register, o plugin está instalado e carregado. Não está ativo ainda — isso só significa que o que o plugin escolheu conectar ao seu evento activate não rodou. As routes, views e hook listeners do plugin já estão ao vivo.
Estado 2 — Activate
$plugin->activate() em Plugin.php:484 é o que o botão "Activate" do admin chama. Quatro passos ordenados:
- Dispara o activate hook:
Hook::fire('activate_plugin_'.$this->name). Todo listener registrado contra esse nome roda — tipicamente o próprio listener Hook::on('activate_plugin_*', ...) do plugin que chama artisan migrate contra a pasta de migrations do plugin. Outros plugins podem registrar listeners adicionais no mesmo evento.
- Re-valida
composer.json: self::validateMetaData($config) verifica que as keys requeridas do plugin (name, version, app_version) estão presentes e bem formadas. Keys faltando lançam antes do flip de status acontecer.
- Set status DB para
active e salva a linha.
- Atualiza o master file:
{ "status": "active", "error": null } — o reset de error limpa qualquer falha de boot anterior para que sweeps de autoload futuros tratem o plugin como saudável.
Activation é idempotente na prática. Re-rodar activate() num plugin já ativo re-dispara o hook (então listeners que rodaram migrate rodariam de novo — a tabela de migrations do Laravel de-dupea arquivos já rodados, então a segunda invocação é no-op), re-valida e escreve o mesmo status. Sem branch especial de "já ativo".
Estado 3 — Disable
$plugin->disable() em Plugin.php:136 é o mais simples dos quatro métodos. Ele só faz isso:
- Set status DB para
inactive.
- Atualiza o master file com o novo status e limpa qualquer field
error.
Esse é o método inteiro. Ele não descarrega nada.
Routes registradas durante o boot() do plugin ficam registradas. Views permanecem montáveis. Hook listeners ainda disparam quando o host dispara o hook deles. O service provider do plugin ainda está carregado no container da aplicação e será carregado de novo na próxima request porque autoloadWithoutDbQuery() lê toda entrada do master file independente do status. Disable é um flip de status, não um unload — o próprio Laravel não suporta desregistrar um service provider depois que ele bota.
É por isso que o plugin acelle/console é o padrão canônico de "features do plugin devem desaparecer quando desabilitado": routes sempre carregam, mas um route middleware chamado console.active aborta com 404 quando Plugin::getByName('acelle/console')->isActive() retorna false. A checagem acontece em toda request, contra o status atual do DB, então desabilitar o plugin faz suas routes retornarem 404 começando na próxima request.
O padrão visible-disable em três passos. (1) Defina um route middleware que checa Plugin::enabled('myvendor/myplugin') e aborta 404 quando false. (2) Registre como um alias de middleware no boot() do seu service provider. (3) Aplique no grupo de route em routes.php. Todo plugin que entrega features user-visible deve seguir esse padrão — sem ele, "desativado" parece idêntico a "ativado" da perspectiva do usuário.
Estado 4 — Delete
$plugin->deleteAndCleanup($keepData = false) em Plugin.php:670 é o teardown completo. Quatro passos ordenados:
- Dispara o delete hook:
Hook::fire('delete_plugin_'.$name, [$keepData]). O listener do esqueleto chama artisan migrate:rollback contra a pasta de migrations do plugin. A flag $keepData é forwarded para que o listener possa opt out de fazer rollback de tabelas que guardam dados de customer — veja a página de database-models para o padrão trabalhado.
- Deleta o diretório do plugin:
$this->deletePluginDirectory() remove recursivamente storage/app/plugins/{vendor}/{name}/. Depois desse passo, o source PHP do plugin sumiu do disco.
- Deleta a linha do DB. A tabela
plugins não referencia mais esse plugin.
- Remove a entrada do master-file:
updatePluginMasterFile($name, null) — o null é o sinal convencional para dropar a entrada em vez de fazer merge de campos novos.
Até a próxima request botar um processo fresco, as routes, views e hooks do plugin ainda estão carregados em memória — o container Laravel in-process não tem conceito de "desregistre o service provider deste plugin". A próxima request lê o master file (agora encolhido), não carrega o plugin, e o estado in-memory é descartado com o ciclo de vida da request anterior.
O master file em cada transição
storage/app/plugins/index.json é a única fonte da verdade em tempo de boot. Toda transição acima escreve nele. Um jeito útil de ver o ciclo de vida é observar como uma entrada de plugin se parece em cada passo:
// Before register: no entry.
{}
// After register:
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After activate:
{
"acmecorp/loyalty": { "status": "active" }
}
// After a boot failure (sticky until cleared by activate):
{
"acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}
// After disable (error cleared, status flipped):
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After delete: no entry.
{}
Três métodos host-side são donos do arquivo: updatePluginMasterFile($name, $params) para merge-writes (passe null como segundo arg para remover a entrada), resetPluginMasterFile() para reconstruir o arquivo a partir de Plugin::all() quando ele fica fora de sync com o DB, e getErroredPluginNames() para ler toda entrada e retornar os names com error não-vazio.
Recuperação de estado quebrado
Três modos de falha aparecem em produção:
1. A linha do plugin no master file está stale ou errada
Comum depois de edits manuais, deploys parciais ou restauração de snapshot de banco. Fix: rode php artisan tinker e chame Plugin::resetPluginMasterFile(). O método itera Plugin::all() do DB e reescreve o arquivo JSON do zero, preservando status e limpando todo field error.
2. O field error de um plugin está setado e a página admin Plugins mostra a pill vermelha
O erro é sticky — setado quando autoloadWithoutDbQuery() envolve uma chamada loadPluginByName() em try/catch e a chamada lança. O erro permanece até ou um activate() bem-sucedido (que seta error => null) ou um disable() (mesmo). Fix: resolva o problema subjacente (autoload.psr-4 faltando, namespace mismatch, classe de service provider faltando), depois clique Activate; o próximo boot vai ter sucesso e o erro vai limpar.
3. A pasta do plugin sumiu mas a entrada do master-file permanece
Acontece depois de um rm -rf manual. Boot ainda tenta carregar o plugin pela entrada do master-file, lança, e grava o erro. Fix: remova a entrada do master-file diretamente com Plugin::updatePluginMasterFile($name, null), ou — se o plugin ainda deve existir — re-upload o source archive e rode Plugin::register($name) de novo para repopular tudo.
Os comandos de console plugin:*
Um comando artisan vem no host: plugin:init. Não existem comandos plugin:activate, plugin:disable ou plugin:delete — esses são ações de admin-UI. Acesso programático vai pelos métodos do model diretamente:
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate(); // → active, runs migration via activate hook
>>> $p->disable(); // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true); // → preserve customer-facing tables
Essa é a mesma superfície que a página admin Plugins usa internamente. Scripts CI, seeders e testes de integração todos alcançam esses métodos diretamente. O deep-dive de Testes cobre o padrão nível-test-suite.
Transições de estado em um diagrama
┌─────────────────────┐
│ not registered │ (no row, no master-file entry)
└──────────┬──────────┘
│ Plugin::register($name)
│ ├─ writes DB row (status=inactive)
│ ├─ writes master file
│ ├─ loads service provider in-process
│ └─ Language::dump() + vendor:publish
▼
┌─────────────────────┐
┌────▶ │ inactive │ ◀───┐
│ └──────────┬──────────┘ │
│ │ │
│ activate()│ │ disable()
│ │ │ ├─ status=inactive
│ │ │ └─ master file updated
│ ▼ │
│ ┌─────────────────────┐ │
│ │ active │ ────┘
│ └──────────┬──────────┘
│ │
│ deleteAndCleanup($keepData)
│ │ ├─ fires delete hook (rollback unless $keepData)
│ │ ├─ removes plugin folder
│ │ ├─ deletes DB row
│ │ └─ removes master-file entry
│ ▼
│ ┌─────────────────────┐
└──────│ not registered │
└─────────────────────┘
(cycle: register again to re-install)
Cinco anti-padrões
1. Tratar disable como se descarregasse o plugin
Routes ainda registram, hooks ainda disparam, views ainda montam. Fix: proteja features user-visible com um middleware Plugin::enabled(...) ou checagem inline, exatamente como o acelle/console.
2. Editar manualmente o master file em produção
Fácil de corromper o JSON. Fix: chame Plugin::updatePluginMasterFile() ou Plugin::resetPluginMasterFile() pelo tinker — ambos validam.
3. rm -rf storage/app/plugins/{vendor}/{name} sem remover a entrada do master
Boot continua tentando carregar o plugin que sumiu e grava o erro. Fix: sempre pareie uma remoção de pasta com Plugin::updatePluginMasterFile($name, null), ou use deleteAndCleanup() que faz os dois.
4. Chamar activate() de dentro do boot() de um service provider
A fase de boot roda uma vez por processo; chamar activate() lá dispara o activate hook em toda request. A migration roda toda vez (idempotente — mas caro), e os listeners de side-effect disparam também. Fix: ativação é uma ação de admin-UI, nunca um side effect de tempo de boot.
5. Esquecer que register acontece antes do activate
Alguns plugins tentam seedar dados default via listener do hook activate e referenciam models Eloquent que dependem das próprias migrations do plugin — mas as migrations não rodaram ainda no primeiro activate. Fix: o listener de migration roda durante activate, antes de qualquer outro listener Hook::on('activate_plugin_*') que possa referenciar as novas tabelas. Ordene seus registros para que a migration vá primeiro (vai no esqueleto — mantenha assim).
Para onde ir em seguida
Ciclo de vida cobre o quando; Testes cobre o verificar. A próxima página percorre o registro de testsuite no phpunit.xml, o padrão de classe base PluginTestCase, asserções de hooks-under-test e o ciclo CI activate-test-delete.