O modelo mental que o resto desta documentação assume.

Um plugin neste codebase é um pequeno pacote Laravel — mas a forma como a aplicação host o carrega não é a forma como o Composer normalmente carrega pacotes. Não há passo de install em vendor/, nenhuma entrada em composer.lock, nenhuma regeneração de autoload. Todo plugin é registrado em runtime, a partir de um único master file JSON, por um Composer\Autoload\ClassLoader novo que o host instancia dentro de AppServiceProvider::boot(). Uma vez que você tem essa imagem, o resto da documentação para desenvolvedores — hooks, drivers de envio, gateways de pagamento, injeção de UI, ciclo de vida — encaixa em torno dela limpinho.

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:

  1. Lê o composer.json de cada plugin.
  2. Registra o namespace PSR-4 declarado ali com uma instância nova de Composer\Autoload\ClassLoader.
  3. 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:

ArquivoResponsabilidade
app/Console/Commands/InitPlugin.phpO ponto de entrada CLI para php artisan plugin:init. Wrapper fino em volta de Plugin::init($name).
app/Model/Plugin.phpO ciclo de vida inteiro: scaffold, register, load, activate, disable, delete, mais a maquinaria do master file.
app/Library/HookManager.phpAs 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.phpAutoload de plugin em tempo de boot + registro de tradução. O único call site que conecta plugins na aplicação rodando.
app/Model/Language.phpMaterializa 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:

ChavePropósito
nameID canônico do plugin. Precisa bater com o diretório em storage/app/plugins/ exatamente. Plugin::register() lança se eles divergem.
autoload.psr-4Mapeia o prefixo de namespace do plugin para src/. Obrigatório — sem ele, loadPluginByName() lança e o plugin não pode bootar.
extra.laravel.providersArray 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-routeO 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, versionAparece 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 vidaHook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
  • A URL do íconeHook::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:

  1. composer.json, copia title / description / version para o model.
  2. Insere ou atualiza a linha em plugins com status = inactive.
  3. Escreve storage/app/plugins/index.json com { "name": { "status": "inactive" } }.
  4. 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.
  5. 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:

  1. 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.
  2. Re-valida o composer.json do plugin contra a lista de chaves obrigatórias do host (name, version, app_version).
  3. Seta o status do banco para active.
  4. 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:

  1. 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.
  2. Recursivamente deleta o diretório do plugin em storage/app/plugins/....
  3. Deleta a linha da tabela plugins do banco.
  4. 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 hookOnde disparaUsado para
layout.head.assetsresources/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_closeMesmos layouts, logo antes de </body>Widgets flutuantes — bolha do chatbox, modais, popover sparkle
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSeçõ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:

  1. O register() do plugin contribui uma entrada Hook::add('add_translation_file', ...) apontando para storage/app/data/plugins/{vendor}/{name}/lang/.
  2. O AppServiceProvider::boot() do host coleta todas as entradas desse tipo e chama $this->loadTranslationsFrom() contra cada uma.
  3. Em todo Plugin::register(), o host chama Language::dump().
  4. 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.
  5. 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.

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.