O que é um plugin aqui
Um plugin é um pacote Laravel autocontido que vive em storage/app/plugins/{vendor}/{name}/ dentro da instalação host do AcelleMail. Carrega seu próprio composer.json, seu próprio namespace PSR-4, seu próprio service provider, suas próprias rotas, views, migrations e traduções. É estruturado exatamente como uma pequena aplicação Laravel — exceto por uma distinção decisiva.
A aplicação host não instala o plugin pelo autoloader Composer da raiz. Não há passo composer require, nenhum diretório vendor/{vendor}/{name}/, nenhuma entrada em composer.lock. Em vez disso, toda vez que a aplicação dá boot, ela faz o seguinte por conta própria:
- Lê o
composer.json de cada plugin.
- Registra o namespace PSR-4 declarado ali com uma instância nova de
Composer\Autoload\ClassLoader.
- Chama
App::register(...) nos service providers listados em extra.laravel.providers.
A decisão foi deliberada. Tratar plugins como pacotes instalados pelo Composer teria feito do composer.json da aplicação host um alvo móvel — toda instalação, desativação ou upgrade mutaria o lockfile. O loader em runtime mantém o grafo de dependências do host estável: plugins entregam com seus próprios metadados e o host pode escanear, ignorar ou reordenar sem tocar em vendor/.
Cinco arquivos que governam o sistema inteiro
Quase todo comportamento no ciclo de vida do plugin é implementado em cinco arquivos na aplicação host. Ler o código-fonte desses é o jeito mais rápido de confirmar qualquer coisa nesta documentação:
| Arquivo | Responsabilidade |
app/Console/Commands/InitPlugin.php | O ponto de entrada CLI para php artisan plugin:init. Wrapper fino em volta de Plugin::init($name). |
app/Model/Plugin.php | O ciclo de vida inteiro: scaffold, register, load, activate, disable, delete, mais a maquinaria do master file. |
app/Library/HookManager.php | As primitivas de injeção que plugins usam para estender o comportamento do core — REGISTRY, EVENT, BEHAVIOR, FILTER. Cerca de 160 linhas, sem dependências. |
app/Providers/AppServiceProvider.php | Autoload de plugin em tempo de boot + registro de tradução. O único call site que conecta plugins na aplicação rodando. |
app/Model/Language.php | Materializa arquivos de tradução de plugin em storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. A indireção que deixa admins editarem traduções pela UI de Idiomas sem tocar nos arquivos-fonte do plugin. |
Juntos esses totalizam bem menos de três mil linhas de código do lado do host. O sistema de plugins é pequeno de propósito — toda restrição que um plugin tem vem de um desses cinco arquivos, e não há outro lugar para olhar.
O fluxo de boot-and-load
Toda requisição, worker de fila, tick de scheduler e comando Artisan passa pela mesma sequência de boot. A fatia relevante para plugins se parece com isto:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
Dois detalhes de implementação nessa sequência têm consequências desproporcionais para autores de plugin:
1. Descoberta no boot nunca consulta o banco
A lista de plugins para carregar vem de storage/app/plugins/index.json, não da tabela plugins do banco. Service providers não têm permissão para consultar o banco com segurança — no momento em que AppServiceProvider::boot() roda, a conexão pode não existir ainda (comandos CLI como artisan db:create) ou o schema pode não ter migrado (setup de teste de CI). Armazenar o registry de boot em um arquivo JSON contorna o problema inteiro.
A tabela do banco ainda existe. Ela armazena o mesmo status que o arquivo JSON, mais metadados voltados ao usuário como title, description e version. A página admin Plugins lê do banco; o loader de boot lê do JSON. Ambos são mantidos em sincronia por Plugin::register(), activate() e disable() — toda mudança de status escreve nos dois stores.
2. autoloadWithoutDbQuery() atualmente carrega todo plugin no index — incluindo os inativos
A implementação atual itera cada entrada em index.json e chama loadPluginByName nela, independente do status. A razão é pragmática: mesmo um plugin inativo precisa ter suas rotas registradas (para que páginas admin continuem funcionando quando um admin clica "desativar" sem recarregar imediatamente) e precisa ter suas traduções disponíveis (para que os dump-clones não fiquem velhos).
A consequência é que "inativo" no sistema de plugins AcelleMail não é o mesmo que "descarregado". A próxima seção precisa a distinção.
O contrato composer.json
O composer.json de um plugin não é só metadata — é o contrato em runtime do qual o loader depende. As chaves que importam são:
| Chave | Propósito |
name | ID canônico do plugin. Precisa bater com o diretório em storage/app/plugins/ exatamente. Plugin::register() lança se eles divergem. |
autoload.psr-4 | Mapeia o prefixo de namespace do plugin para src/. Obrigatório — sem ele, loadPluginByName() lança e o plugin não pode bootar. |
extra.laravel.providers | Array de nomes de classe totalmente qualificados. O loader chama App::register() em cada um. Obrigatório se o plugin quer registrar rotas, views, hooks ou qualquer outra coisa. |
extra.setting-route | O controller@method ao qual a página admin Plugins linka como o botão "Configurações" do plugin. Opcional — plugins sem configuração podem omitir. |
title, description, version | Aparece na listagem admin Plugins. title é obrigatório; os outros caem em defaults. |
O mapeamento de autoload é registrado em runtime, não no install. Você não precisa rodar composer dump-autoload depois de editar o mapa PSR-4 do plugin — o host instancia um ClassLoader novo a cada requisição e re-lê o arquivo. Isso também é porque mudar o namespace de um plugin não exige mais que um search-and-replace mais uma requisição para o host.
O master file (storage/app/plugins/index.json)
O master file é um objeto JSON plano chaveado por nome de plugin. Cada entrada armazena no mínimo um status, mais uma string error opcional quando a tentativa de boot mais recente falhou. Um arquivo típico se parece com:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
Três métodos do lado do host são donos desse arquivo. Toda mudança de status passa por um deles:
Plugin::updatePluginMasterFile($name, $params) — merge-write da entrada de um único plugin. Passe null como segundo argumento para remover a entrada inteiramente (caminho de delete).
Plugin::resetPluginMasterFile() — reconstrói o arquivo do zero iterando Plugin::all(). Usado como recovery quando o JSON fica corrompido ou fora de sincronia com o banco.
Plugin::getErroredPluginNames() — lê cada entrada, retorna os nomes com error não vazio. A listagem admin Plugins usa isso para empurrar plugins quebrados para o final e mostrar a pill vermelha de erro.
A chave error é setada quando autoloadWithoutDbQuery() envolve uma chamada loadPluginByName() em try/catch e a chamada lança. A mensagem da exceção é gravada para que a UI admin tenha algo para mostrar sem re-executar a falha. Reativar um plugin limpo limpa o campo automaticamente.
O master file é a única fonte da verdade em tempo de boot. Se você precisar se recuperar de um plugin travado (a UI admin está fora, o banco está offline), edite storage/app/plugins/index.json diretamente. A próxima requisição lê o estado atualizado e se comporta de acordo. A linha do banco é a metadata de longo prazo; o arquivo JSON é o registry de runtime.
Timing register() vs boot()
O Laravel roda o método register() de cada service provider primeiro, em ordem de registro, antes de chamar qualquer boot(). Isso é Laravel conhecido — mas tem consequências diretas no sistema de plugins.
O que vai em register()
- Constantes e bindings — esses precisam existir antes do
boot() do próprio host rodar.
- O hook
add_translation_file — e só esse hook. O AppServiceProvider::boot() do host chama Hook::collect('add_translation_file') na sua própria fase de boot. Quando o boot() de um plugin roda, esse loop já terminou. Se um plugin registra sua entrada de tradução em boot(), ela nunca é pega — e trans('myname::messages.intro') retorna a chave literal.
O que vai em boot()
- Rotas e views —
$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
- Asset publishes —
$this->publishes([...], 'plugin').
- Listeners de eventos de ciclo de vida —
Hook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
- A URL do ícone —
Hook::set('icon_url_{vendor}/{name}', ...).
- Todo outro hook — REGISTRY
add, EVENT on, BEHAVIOR set, FILTER modify. Qualquer coisa que depende de bindings de container, config ou outros plugins.
Não chame $this->loadTranslationsFrom(...) no boot() do seu plugin. O host já conectou o namespace pelo hook add_translation_file, apontando para os arquivos dumpados de runtime em storage/app/data/plugins/.... Um segundo loadTranslationsFrom do boot() do seu plugin sobrescreve a dica do host e re-aponta o namespace para o master file em resources/lang/.... O sintoma visível é que edições admin na UI de Idiomas param de fazer efeito em runtime — os clones dumpados viram arquivos zumbi. Use só o hook.
Por que plugins inativos ainda afetam o app
A chamada autoloadWithoutDbQuery() no boot carrega todo plugin em index.json independente de status. Então um plugin "inativo" ainda tem todos esses registrados com o host:
- Suas rotas — declaradas por
$this->loadRoutesFrom(...) em boot().
- Suas views — declaradas por
$this->loadViewsFrom(...).
- Seus aliases de middleware — registrados pelas APIs Laravel padrão.
- Seus listeners de hook — cada
Hook::add, Hook::on, Hook::modify, Hook::set ainda dispara.
- Seus fragmentos de UI — qualquer coisa contribuída via
layout.head.assets, layout.body.before_close, admin.sidebar.groups ou hooks REGISTRY de page-slot ainda aparece.
O que a ativação de fato adiciona é o que o autor do plugin conectou a activate_plugin_{vendor}/{name}. O listener do esqueleto roda a migration. Não há um passo implícito "registrar rotas quando ativo" ou "remover rotas quando inativo" — as rotas foram registradas no momento em que a aplicação deu boot.
Se uma feature precisa realmente sumir quando um admin desativa o plugin, o autor do plugin tem que protegê-la explicitamente. O padrão convencional vive em storage/app/plugins/acelle/console: rotas sempre carregam, mas um route middleware chamado console.active aborta com 404 quando Plugin::getByName('acelle/console')->isActive() retorna false. Copie esse padrão quando "desativado" deve significar "não alcançável".
O mesmo se aplica a hooks de UI. Se uma bolha de chatbox injetada por layout.body.before_close deve esconder quando o plugin está inativo, o corpo da closure precisa checar Plugin::enabled('myvendor/myplugin') primeiro e retornar null quando false. O host filtra retornos falsy automaticamente antes de renderizar.
Ciclo de vida: register / activate / disable / delete
Quatro estados, quatro métodos do lado do host. Cada um é preciso sobre o que muda e o que não muda.
Register / install
Plugin::register($name) é o ponto de entrada — é chamado automaticamente no fim de plugin:init e em todo upload bem-sucedido pela UI admin. Os cinco passos são:
- Lê
composer.json, copia title / description / version para o model.
- Insere ou atualiza a linha em
plugins com status = inactive.
- Escreve
storage/app/plugins/index.json com { "name": { "status": "inactive" } }.
- Chama
Plugin::load($withServiceProvider = true) — registra o prefixo PSR-4 e boota o service provider imediatamente, para que quaisquer rotas / views / hooks fiquem vivos no processo atual.
- Chama
Language::dump() para materializar arquivos de tradução, depois roda vendor:publish --tag=plugin --force para copiar quaisquer assets empacotados em public/plugins/....
Depois do register o plugin está instalado e carregado. A única coisa faltando é o que o plugin escolheu conectar ao seu evento activate — tipicamente uma execução de migration.
Activate
$plugin->activate() é chamado pelo botão "Ativar" da UI admin (e por testes / seeders chamando o model diretamente). Faz quatro coisas, em ordem:
- Dispara
Hook::fire('activate_plugin_'.$name). O listener do esqueleto roda artisan migrate contra storage/app/plugins/{vendor}/{name}/database/migrations. Outros plugins podem registrar listeners adicionais — comportamento REGISTRY, todo listener dispara.
- Re-valida o
composer.json do plugin contra a lista de chaves obrigatórias do host (name, version, app_version).
- Seta o
status do banco para active.
- Atualiza o master file:
{ "status": "active", "error": null } — limpando qualquer erro de boot anterior.
Disable
$plugin->disable() só:
- Seta o
status do banco para inactive.
- Atualiza o master file com o novo status e limpa qualquer
error gravado.
Não descarrega rotas, views, service providers, listeners de hook ou qualquer outra coisa que foi registrada no boot. O host não tem conceito de "desregistrar um service provider" — o próprio Laravel não suporta isso. Disable é um flip de status, não um unload.
Delete
$plugin->deleteAndCleanup($keepData = false) percorre o teardown completo:
- Dispara
Hook::fire('delete_plugin_'.$name, [$keepData]). O listener do esqueleto roda migrate:rollback; $keepData = true pode pular isso para plugins que são donos de dados que o admin quer preservar.
- Recursivamente deleta o diretório do plugin em
storage/app/plugins/....
- Deleta a linha da tabela
plugins do banco.
- Remove a entrada do master file.
Até a próxima requisição bootar um processo novo, o service provider do plugin ainda está carregado em memória. A próxima requisição lê o master file (agora encolhido), não carrega o plugin, e o estado em-processo é descartado com o ciclo de vida da requisição.
Duas camadas de injeção
Um plugin influencia a aplicação host por duas camadas paralelas. Distinguir entre elas é o que faz o resto da documentação mapear limpinho para código.
Camada 1 — Registro Laravel
Pelo service provider, um plugin usa as APIs container Laravel padrão para estender a aplicação:
$this->loadRoutesFrom(__DIR__ . '/../routes.php') — adiciona a superfície HTTP do plugin.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — expõe views Blade no namespace myname::view.
$this->publishes([...], 'plugin') — copia assets empacotados em public/plugins/{vendor}/{name}/ do host no install.
- Aliases de middleware, bindings de container, comandos de console, tarefas agendadas, listeners de fila — tudo que o próprio Laravel suporta.
Camada 2 — Injeção baseada em Hook
O host chama as primitivas de App\Library\HookManager em pontos de extensão cuidadosamente escolhidos. Plugins registram listeners contra esses pontos para participar. Há exatamente quatro padrões: REGISTRY, EVENT, BEHAVIOR, FILTER. O próximo deep-dive — O sistema de Hooks — cobre cada um por inteiro.
Duas coisas para saber agora: (1) todo hook que o host dispara é um contrato estável — uma vez publicados, o nome e a assinatura não mudam entre releases. (2) BEHAVIOR é exclusivo — se dois plugins tentam dar Hook::set no mesmo nome, a segunda chamada lança imediatamente. Não há override silencioso; conflitos aparecem no boot, não em produção.
O codebase entrega três hooks REGISTRY em nível de layout que quase todo plugin que estende UI usa:
| Chave de hook | Onde dispara | Usado para |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php, antes de @yield('head') | CSS / JS que precisam carregar antes do conteúdo da página (estilos do chatbox, scripts do popover sparkle) |
layout.body.before_close | Mesmos layouts, logo antes de </body> | Widgets flutuantes — bolha do chatbox, modais, popover sparkle |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Seções de barra lateral admin contribuídas por plugin |
Os três seguem o mesmo idioma: cada callback retorna HTML renderizado ou null; o host itera com array_filter e emite cada fragmento com {!! !!}. Retornar null é a forma convencional de proteger uma contribuição por feature flag ou status do plugin sem lançar.
Fluxo de tradução em runtime
Traduções de plugin não são servidas direto da pasta-fonte resources/lang/ do plugin. O fluxo é indireto, e essa indireção é o que deixa admins editarem traduções pela UI de Idiomas do host sem se comprometer com os arquivos-fonte do plugin. A sequência verificada:
- O
register() do plugin contribui uma entrada Hook::add('add_translation_file', ...) apontando para storage/app/data/plugins/{vendor}/{name}/lang/.
- O
AppServiceProvider::boot() do host coleta todas as entradas desse tipo e chama $this->loadTranslationsFrom() contra cada uma.
- Em todo
Plugin::register(), o host chama Language::dump().
Language::dump() lê o master file do plugin em resources/lang/en/messages.php e copia para storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php para cada locale suportado.
- A UI admin de Idiomas edita os arquivos dumpados de runtime. O master file fonte do plugin fica intocado.
Dois caminhos para lembrar:
- Master file (você edita isso na fonte):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- Arquivos de runtime (auto-gerados, o que o app de fato lê):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
Quando você edita o master file, rode php artisan translation:upgrade para re-sincronizar o master em todos os arquivos de runtime de locale (preservando quaisquer traduções que admins editaram pela UI de Idiomas). A mecânica completa — master vs runtime, semântica de upgrade, fallback por locale — ganha um deep-dive dedicado em Traduções.
O que isso implica para autores de plugin
Cinco regras caem da arquitetura acima. Internalizá-las transforma a maior parte da complexidade superficial no resto da documentação em uma checagem contra essa lista.
- Trate
boot() como a fase de registro. Rotas, views, hooks, listeners de ciclo de vida — quase tudo vai aqui. A única coisa que vai em register() é o hook add_translation_file (porque o host coleta antes que qualquer boot() de plugin rode).
- Inativo não significa descarregado. Qualquer coisa que você registra no boot fica viva independente do status
active / inactive. Se uma feature precisa realmente sumir quando desabilitada, proteja explicitamente com um route middleware ou um check Plugin::enabled(...) dentro da closure do hook.
- Edite traduções pelo master file, nunca por
loadTranslationsFrom() direto. Os clones dumpados em storage/app/data/plugins/... são o que runtime lê. Apontar seu namespace para o diretório master você mesmo sobrescreve a dica do host e quebra a UI de Idiomas.
- Mantenha o
composer.json fino e estável. O loader em runtime lê em toda requisição. autoload.psr-4, extra.laravel.providers, name, title são as chaves que o host de fato usa. Adicionar chaves extras está ok mas não faz nada.
- Os quatro padrões de hook são o único contrato. Quando se pegar querendo "importar" uma classe do core para estender — pause. O contrato de plugin é unidirecional: o core declara hooks, plugins reagem. Se o ponto de extensão que você precisa ainda não existe como hook, o movimento certo é abrir uma issue contra o host, não dar
use Acelle\Model\Customer a partir do controller do seu plugin.
Para onde ir em seguida
Você tem a arquitetura. Duas páginas transformam esse modelo mental nas APIs de uso diário que você vai pegar:
- O sistema de Hooks — os quatro padrões em profundidade, com call-sites reais extraídos do core via grep. As semânticas de conflito, quando usar qual padrão e os anti-padrões que parecem certos mas quebram em produção.
- Injeção de UI — os hooks em nível de layout acima, mais o contrato
page.{controller}.{action}.{slot} que deixa um plugin injetar um card em uma página existente sem dar fork em uma única Blade.
Quando você estiver pronto para entregar um plugin de feature real, os exemplos trabalhados são Drivers de envio (Postal MTA de ponta a ponta) e Gateways de pagamento (Paddle como gateway regional). Para um exercício completo de leitura-compreensão, a vitrine do Aurius percorre o plugin complexo canônico: oito models, quatorze migrations, dezoito locales e cada superfície de hook usada em produção.