Pré-requisitos
Um plugin neste codebase é um pequeno pacote Laravel. Antes de fazer scaffold de um, garanta que a instalação host do AcelleMail que você está estendendo já está rodando, e que você tem uma toolchain PHP funcionando na sua máquina local. Os comandos CLI abaixo assumem que você está na raiz da aplicação (o diretório que contém o arquivo artisan).
Aplicação host
- AcelleMail v4.x instalado e servindo requisições. O loader de plugin é parte do
App\Providers\AppServiceProvider — builds 3.x mais antigos não têm Plugin::autoloadWithoutDbQuery().
- Um worker de fila, scheduler ou requisição web capaz de atingir a raiz da aplicação — o loader roda no boot, não sob demanda.
- Acesso de escrita em
storage/app/plugins/. O comando Artisan escreve o scaffold aqui, não em vendor/.
Conhecimento de PHP que vale refrescar
O sistema de plugins se apoia fortemente em um punhado de fundamentos de PHP e Laravel. Se algum deles parece enferrujado, pause e revise a documentação relevante antes de fazer scaffold — debugar um plugin com o namespace errado declarado no composer.json é muito mais difícil do que acertar na primeira.
- Autoloading PSR-4. O
composer.json do plugin mapeia um prefixo de namespace para o diretório src/. O AcelleMail registra esse mapeamento com um Composer\Autoload\ClassLoader novo no boot — então a declaração de namespace em cada arquivo PHP precisa bater com o mapeamento do composer.json exatamente, capitalização incluída.
- Closures e a palavra-chave
use. Quase todo listener de hook é uma closure. Quando a closure precisa de uma variável externa, você precisa capturá-la explicitamente. Esquecer isso é a fonte mais comum de erros undefined variable em código de plugin.
register() vs boot() num service provider. O Laravel roda o register() de cada provider primeiro, depois o boot() de cada um. Hooks listados em register() podem rodar antes das suas dependências estarem prontas; hooks listados em boot() rodam tarde demais para o coletor de tradução. Ambos são footguns reais — veja Sete erros do primeiro dia.
- Eloquent, Blade, Routes, Facades. Migrations de plugin usam o
Schema builder padrão, views de plugin são arquivos Blade comuns, rotas de plugin usam Route::group(...). Nada em um plugin é sob medida — os arquivos gerados são Laravel puro.
Você não precisa publicar o plugin no Packagist, rodar composer install dentro da pasta do plugin nem registrar nada no composer.json raiz do host. O loader em runtime cuida de cada passo.
Regras de nomenclatura — leia uma vez, economize uma hora
Todo plugin tem uma identidade no formato {vendor}/{name} — por exemplo Aurius, aix/sample, athena/evs. Essa identidade é a chave canônica na tabela plugins do banco, no diretório storage/app/plugins/, no master file storage/app/plugins/index.json e nos nomes de hook de ciclo de vida (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).
O validador em App\Model\Plugin::init() impõe um conjunto de regras pequeno e conservador (regex canônico: ^[a-z0-9]+\/[a-z0-9]+$ com min:2 max:32 por lado):
- Apenas letras minúsculas e dígitos. Sem underscores, sem traços, sem maiúsculas. A orientação anterior que permitia underscores foi superada — se você vê
my_plugin em um README antigo, não é mais válido.
- Dois a trinta e dois caracteres por lado.
a/sample falha (vendor curto demais); team/x falha (nome curto demais).
- Exatamente uma barra. Vendor e nome. Sem aninhamento.
A regra de interseção conservadora vem de uma faxina de 2026-04 que alinhou Plugin::init() com Plugin::getStoragePathByName(). Ambos os validadores agora concordam no mesmo regex — não há mais como um nome passar no scaffold e depois falhar no load.
Escolha o segmento vendor com cuidado. O vendor é parte de cada namespace, de cada prefixo de URL no routes.php do seu plugin e de cada chave de tradução que o plugin emite. Renomear depois significa search-and-replace em cada arquivo. acmecorp/loyalty é inequívoco; x/loyalty é inválido (vendor curto demais); acmecorp/loyaltypoints está bom.
O comando de scaffold
Da raiz da aplicação, rode:
php artisan plugin:init {vendor}/{name}
Para um exemplo trabalhado vamos usar acmecorp/loyalty — o resto desta página assume esse nome. Substitua pelo seu próprio quando rodar o comando.
$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder
A mensagem de sucesso é impressa por App\Console\Commands\InitPlugin, que é um wrapper fino sobre o método de model App\Model\Plugin::init($name). Esse método faz tudo que o resto desta página descreve — validação, cópia do scaffold, render Twig, rename de arquivo, depois uma chamada encadeada a Plugin::register($name) que insere a linha no banco e boota o service provider.
Quando o prompt retorna, o plugin já está carregado na aplicação rodando como pacote inativo. Rotas declaradas em seu routes.php são alcançáveis, views são renderizáveis e quaisquer hooks que o service provider registrou estão vivos. A única coisa que a ativação adiciona é o que o autor do plugin conectou ao evento activate_plugin_{vendor}/{name} — tipicamente uma execução de migration.
O que foi gerado
O comando Artisan escreve um pequeno conjunto de arquivos iniciais em storage/app/plugins/{vendor}/{name}/, renderiza placeholders Twig dentro deles e renomeia a migration placeholder. A lista exata de arquivos é hard-coded em Plugin::init() — oito arquivos com conteúdo renderizado mais alguns assets estáticos. Nenhum desses arquivos é especial; são Laravel puro e você está livre para deletar, renomear ou estender.
A árvore de diretório em disco depois que o comando termina:
storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│ └── migrations/
│ └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│ ├── lang/
│ │ └── en/
│ │ └── messages.php
│ └── views/
│ └── index.blade.php
└── src/
├── Controllers/
│ └── DashboardController.php
├── Models/
│ └── Setting.php
└── ServiceProvider.php
Os oito arquivos de uma olhada
| Arquivo | Para que serve |
composer.json | Contrato de runtime: name, autoload.psr-4 e extra.laravel.providers são obrigatórios. Sem eles o loader não consegue registrar o namespace ou bootar o provider. |
src/ServiceProvider.php | O único ponto de entrada que o Laravel vê. Registra traduções em register(), depois rotas, views, hooks de ciclo de vida e a URL do ícone em boot(). |
src/Controllers/DashboardController.php | Uma amostra descartável. Retorna a view index.blade.php empacotada. Substitua à vontade. |
src/Models/Setting.php | Um model Eloquent ligado à primeira migration do plugin. O nome da tabela tem namespace {vendor}_{name}_settings para que plugins não colidam no mesmo banco. |
routes.php | Carregado pelo service provider. Declara tanto a rota que serve o ícone (usada pela página admin Plugins) quanto uma rota de dashboard de amostra em plugins/{vendor}/{name}. |
resources/views/index.blade.php | A view Hello World renderizada pelo DashboardController. Substitua pela sua UI real. |
resources/lang/en/messages.php | O arquivo master de tradução. Language::dump() copia para storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ em runtime — os arquivos dumpados são o que a aplicação de fato lê. |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | A primeira migration. Roda só quando o plugin é ativado, faz rollback quando é deletado. O filename é o único cujos placeholders o Twig em si não renderiza — Plugin::init() renomeia via uma passagem separada de str_replace. |
Um plugin real em produção cresce além desse mínimo. A referência canônica no codebase é storage/app/plugins/Aurius/ — oito models Eloquent, quatorze migrations, dezoito locales, mais de sessenta views, um grupo na barra lateral do admin, uma bolha de UI do chatbox e seus próprios jobs ligados à fila. O esqueleto Hello World é intencionalmente mínimo para que você troque peças uma de cada vez sem aprender cada subsistema de uma vez. Controllers extras vão em src/Controllers/, models extras em src/Models/, serviços extras em src/Services/, migrations adicionais em database/migrations/.
O que Plugin::register() fez nos bastidores
A linha de output diz criado & carregado, e isso é preciso. Entre copiar arquivos e imprimir a mensagem de sucesso, Plugin::init() chama Plugin::register($name), que executa cinco passos distintos:
- Lê o
composer.json do plugin. O campo name precisa bater com o diretório exatamente (acmecorp/loyalty) — uma divergência lança uma exceção composer name in composer.json is expected to be ….
- Cria ou atualiza a linha na tabela
plugins do banco. title, description e version são puxados do metadata do composer. Status é setado para inactive.
- Escreve o master file.
storage/app/plugins/index.json é o registry de boot — AppServiceProvider::boot() lê esse arquivo para decidir quais plugins carregar, em toda requisição, sem tocar no banco. Ativação e disable depois mutam o mesmo arquivo.
- Carrega o service provider imediatamente. O
boot() do plugin roda no processo atual, então quaisquer rotas / views / hooks que ele registra ficam vivos antes da próxima requisição.
- Materializa arquivos de tradução.
Language::dump() lê cada entrada de hook add_translation_file, copia os master files para storage/app/data/plugins/... e termina rodando vendor:publish --tag=plugin --force para que quaisquer assets empacotados caiam em public/plugins/....
O modelo mental que vale lembrar: "instalado" já significa "carregado". Ativação é puramente um flip de status mais o que o autor do plugin conectou para disparar no evento de ativação. Não há um passo separado de registrar as rotas que a ativação dispara — as rotas são registradas no momento em que plugin:init termina.
Plugins inativos ainda estão carregados. A implementação atual de Plugin::autoloadWithoutDbQuery() carrega cada plugin listado em index.json, independente do status. Se uma feature precisa realmente sumir quando o admin desativa o plugin, o autor do plugin tem que protegê-la explicitamente — um route middleware que checa Plugin::getByName($name)->isActive() e aborta com 404 é o padrão convencional. O próprio plugin admin-console da plataforma core é o exemplo canônico.
Ativar o plugin
Com o plugin scaffoldado e inativo, o próximo passo é marcá-lo como ativo para que seu listener activate_plugin_{vendor}/{name} rode a migration. Dois caminhos:
Pela UI admin
Entre como admin, abra /rui/admin/plugins, encontre a entrada Loyalty e clique em Ativar. A página renderiza o ícone servido pelo seu routes.php (o placeholder entrega um icon.svg na raiz do plugin — substitua pelo seu para marcar a entrada).
Programaticamente (testes ou seeding)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
Qualquer caminho dispara Hook::fire('activate_plugin_acmecorp/loyalty'). O service provider do esqueleto registrou um listener Hook::on(...) para esse evento em boot() — o listener chama Artisan::call('migrate', ['--path' => ..., '--force' => true]), que cria a tabela acmecorp_loyalty_settings.
Visite /plugins/acmecorp/loyalty em um navegador e a página Hello World empacotada renderiza. O blockquote @{{ trans('loyalty::messages.intro') }} puxa do arquivo de tradução dumpado em storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.
Suas primeiras edições
O esqueleto é intencionalmente mínimo para que você troque peças uma de cada vez sem aprender cada subsistema de uma vez. Uma ordem razoável:
- Atualize o
composer.json. Defina title, description e version reais. A página admin Plugins renderiza esses campos.
- Adicione uma migration real. Coloque um novo arquivo em
database/migrations/ com timestamp maior que o existente. Vai rodar no próximo activate (ou depois de um ciclo deactivate-then-reactivate).
- Adicione um model real. O esqueleto entrega
Setting como placeholder. Adicione o seu em src/Models/; nomeie como {Vendor_class}\{Name_class}\Models\YourModel. Os nomes de classe são auto-derivados do vendor/name minúsculo — acmecorp vira Acmecorp e loyalty vira Loyalty.
- Substitua o
DashboardController. Adicione os controllers que sua feature de fato precisa. Mantenha-os finos — empurre lógica de negócio para classes em src/Services/.
- Substitua as views. A
index.blade.php empacotada usa Bootstrap 5 via CDN. A maioria dos autores de plugin remove e estende o layout da aplicação host.
- Adicione hooks em
ServiceProvider::boot(). Veja o deep-dive do sistema de Hooks para os quatro padrões. O esqueleto já demonstra EVENT (Hook::on) e BEHAVIOR (Hook::set) — REGISTRY e FILTER são os próximos dois a aprender.
Sete erros do primeiro dia e como corrigir
Quase todo report de novos autores de plugin cai em uma destas sete categorias. Cada uma está ancorada em código que entrega em App\Model\Plugin ou App\Providers\AppServiceProvider, então os sintomas são previsíveis.
1. Nomenclatura viola o validador
plugin:init lança com Plugin name must be in the "author/name" format ou Author name "..." is invalid. Only lowercase letters and digits are allowed. Causa: o regex ^[a-z0-9]+\/[a-z0-9]+$ com min:2 max:32 por lado rejeita underscores, traços, letras maiúsculas ou lados menores que dois caracteres.
Correção: use apenas letras minúsculas e dígitos — por exemplo acmecorp/loyalty, não acme_corp/loyalty-points.
2. Nome do composer.json não bate com a pasta
Depois do scaffold, Plugin::register() valida que o name no composer.json renderizado bate com a pasta em storage/app/plugins/. Editar o JSON para um vendor ou nome diferente sem renomear o diretório lança Plugin name in composer.json is expected to be '{folder}', found '{json}'.
Correção: renomeie o diretório e o JSON em sincronia, ou rode plugin:init de novo com o novo nome.
3. autoload.psr-4 ausente ou malformado
loadPluginByName() lança Cannot boot plugin '{name}'. No 'autoload' found in composer.json (ou a variante correspondente para 'autoload.psr4') quando o bloco autoload é removido ou mal escrito. O runtime precisa desse mapa para registrar o namespace; sem ele nada em src/ pode ser instanciado.
Correção: mantenha a entrada scaffoldada autoload.psr-4. O prefixo de namespace que ela declara (Acmecorp\Loyalty\) precisa bater com a declaração de namespace no topo de cada arquivo PHP em src/.
4. Declaração de namespace não bate com o composer.json
O autoloader do PHP resolve Acmecorp\Loyalty\Controllers\DashboardController para src/Controllers/DashboardController.php tirando o prefixo Acmecorp\Loyalty\ declarado no composer.json. Se o arquivo declara namespace AcmeCorp\Loyalty\Controllers (C maiúsculo em AcmeCorp), o autoloader não encontra. Sintomas: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found já na primeira requisição.
Correção: a declaração de namespace em cada arquivo PHP em src/ precisa usar a capitalização exata derivada do vendor/name minúsculo. Para acmecorp/loyalty, isso é Acmecorp\Loyalty. Plugin::makeClassNameFromString() aplica ucfirst só — não há casing inteligente.
5. Hook de tradução registrado em boot() em vez de register()
AppServiceProvider::boot() chama Hook::collect('add_translation_file') na sua própria fase de boot. Quando o boot() de um plugin roda, esse loop já terminou — adicionar a entrada de tradução ali significa que ela nunca é pega, e trans('loyalty::messages.intro') retorna a chave literal.
Correção: registre traduções em register(), exatamente como o esqueleto faz. Os hooks de ciclo de vida para activate_plugin_* e delete_plugin_* ainda pertencem a boot().
6. Chamar $this->loadTranslationsFrom(...) em boot()
Um instinto comum é chamar o loadTranslationsFrom() do Laravel diretamente além do hook. Como o boot() do plugin roda depois do AppServiceProvider::boot, a segunda chamada sobrescreve a dica de namespace que apontava para os arquivos dumpados de runtime (storage/app/data/plugins/...) e re-aponta para o master file (storage/app/plugins/.../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.
Correção: use apenas o hook add_translation_file. Não chame também loadTranslationsFrom().
7. Hooks registrados em register() que dependem de outros plugins ou do kernel
register() roda antes que o register() de todos os outros providers tenha completado e bem antes de qualquer boot(). Código que precisa do banco, serviços de outro plugin ou qualquer singleton que é conectado em outro register() pode falhar com Class not found ou Target class does not exist. O único hook que pertence a register() é add_translation_file (porque ele precisa rodar antes do loop collect do AppServiceProvider::boot).
Correção: coloque todo outro hook em boot(). Se você absolutamente precisa que algo rode cedo, proteja com app()->runningInConsole() ou isInitiated() primeiro.
Checklist passo a passo
A sequência completa para entregar um plugin funcional, de ponta a ponta:
php artisan plugin:init {vendor}/{name} — scaffold.
- Edite
composer.json — defina title, description, version reais.
- Escreva suas migrations em
database/migrations/.
- Adicione models em
src/Models/.
- Adicione controllers em
src/Controllers/.
- Adicione views em
resources/views/.
- Declare rotas em
routes.php.
- Conecte tudo em
ServiceProvider::boot() — views, rotas, hooks, asset publishes.
- Entre no admin → Plugins → Ativar. A migration roda automaticamente.
Quando algo der errado, dois pontos de entrada de debug cobrem quase todo caso. storage/logs/laravel.log captura qualquer exceção lançada durante o boot, incluindo as levantadas dentro de loadPluginByName() enquanto registra o autoload. O campo error em cada linha de storage/app/plugins/index.json mostra a falha de boot mais recente daquele plugin e é o que a página admin Plugins usa para mostrar a pill vermelha de erro — limpar o arquivo reativando o plugin (ou deletando e reinstalando) reseta o estado de erro.
Para onde ir em seguida
Você tem o scaffold, o ciclo de vida e os sete erros que controlam a maior parte do debug de primeiro dia. As próximas duas páginas dão o modelo mental que o resto da documentação assume:
- Arquitetura de plugins — o fluxo de load no boot, por que plugins inativos ainda são autoloadados, o mecanismo do master file e a diferença entre
register() e boot() no nível de runtime.
- O sistema de Hooks — os quatro padrões (REGISTRY, EVENT, BEHAVIOR, FILTER), quando reachar por cada um e as semânticas de conflito que fazem BEHAVIOR lançar em colisão em vez de sobrescrever silenciosamente.
Quando 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 trabalho de UI, Injeção de UI cobre os hooks de layout/sidebar/page-slot que deixam um plugin montar uma bolha de chatbox ou um painel de configurações sem dar fork em uma única Blade.