O mapa de quatro padrões
Todo hook no codebase cai exatamente em uma de quatro formas. A forma determina as semânticas de conflito, o handling de valor de retorno e que tipo de interação faz sentido entre core e plugins. Pegar o padrão errado aparece depois como um caso de borda estranho — saber qual é qual de cara economiza reescrever a integração.
| Padrão | Registrar | Executar | Retorna | Conflito |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | array de cada valor de retorno de callback | Merge — cada callback roda, cada resultado fica |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | nada — valores de retorno são descartados | All-fire — cada listener roda, efeitos colaterais compõem |
| BEHAVIOR | Hook::set($name, $cb) ou setIfEmpty | Hook::perform($name, [...args]) | o que o único callback registrado retorna | Exclusivo — segundo set no mesmo nome lança imediatamente |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | o valor, transformado por cada callback em ordem de registro | Chain — cada callback recebe o output do anterior |
Um modelo mental útil: REGISTRY responde "o que você quer contribuir"; EVENT responde "você queria saber"; BEHAVIOR responde "como eu devo fazer isso"; FILTER responde "no que isso deveria virar". As próximas quatro seções cobrem cada um em profundidade, com os call-sites reais que entregam no core.
REGISTRY — add() + collect()
Um plugin contribui um ou mais itens para uma lista nomeada. O host chama collect() para obter um array de cada contribuição. Cada callback roda, cada valor de retorno é capturado, em ordem de registro.
Mecânica
// Plugin (in ServiceProvider::boot())
Hook::add('register_sending_server_driver', fn() => [
'type' => 'postal',
'driver' => '\\AcmeCorp\\Postal\\Driver',
'name' => 'Postal MTA',
]);
// Core (app/Model/SendingServer.php:191)
foreach (Hook::collect('register_sending_server_driver') as $meta) {
$drivers[$meta['type']] = $meta['driver'];
}
Hooks REGISTRY reais no core
| Chave do hook | Onde o host coleta | O que os plugins contribuem |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | Metadata da classe driver — type, driver (FQCN), label |
register_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:120 | Metadata do select-form "Adicionar servidor" — ícone, nome, descrição, create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | Nomes de campo do form de config por driver — ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | Descriptor de arquivo de tradução — pasta de locale, prefixo, master file |
captcha_method | app/Model/Setting.php:290 | Metadata de provedor captcha — id, label, closure de render |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | Fragmentos HTML de banner mostrados acima do diálogo de import de lista |
generate_big_notice_for_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:235 | Banners HTML para a página de detalhe do servidor de envio |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | Strings CSS / JS injetadas antes de @yield('head') |
layout.body.before_close | Same files, before </body> | HTML de widget flutuante — bolhas de chatbox, modais, popovers sparkle |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Seções de barra lateral admin contribuídas por plugin |
Quando usar REGISTRY
- Múltiplos plugins podem contribuir (drivers de envio, métodos captcha, itens de sidebar).
- O host quer cada contribuição, não só a última.
- As contribuições compõem sem conflito — itens de lista, entradas de menu, fragmentos de banner, metadados de configuração.
Convenção de nomenclatura
Dentro do codebase, nomes REGISTRY tendem a ler como uma verb-phrase que descreve a contribuição: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Nomes com verbo no singular (register_*, add_*) sinalizam "contribuir um destes", mas a mecânica collect() ainda junta cada contribuição — não há nada no lado do registro que limite um plugin a uma única entrada.
EVENT — on() + fire()
Um plugin reage quando algo acontece no host. Valores de retorno são descartados — o contrato é unidirecional: o core notifica, plugins compõem efeitos colaterais. Cada listener roda, em ordem de registro.
Mecânica
// Plugin (in ServiceProvider::boot())
Hook::on('customer_added', function ($customer) {
LoyaltyPoints::award($customer, 100, 'welcome_bonus');
});
// Core (app/Model/Customer.php:1410)
Hook::fire('customer_added', [$customer]);
Hooks EVENT reais disparados pelo core
| Nome do hook | Onde dispara | Args bag |
customer_added | app/Model/Customer.php:1410 + app/Http/Controllers/UserController.php:69 | [$customer] |
user_added | app/Model/User.php:812 | [$user] |
new_subscription | app/Library/OrderFulfillment/Handlers/SubscriptionNewHandler.php:41 | [$subscription] |
plan_changed | app/Services/Subscription/SubscriptionManagementService.php:531 | [$customer, $oldPlan, $newPlan] |
subscription_cancelled | app/Services/Subscription/SubscriptionManagementService.php:212 | [$subscription] |
subscription_terminated | Conectado pelo AppServiceProvider | [$subscription] |
after_verify_dkim_against_aws_ses | app/SendingServers/Drivers/Vendors/Amazon/AmazonBaseDriver.php:447 | [$domain, $tokens] |
activate_plugin_{vendor}/{name} | app/Model/Plugin.php:487 | (sem args) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData] — default false |
Quando usar EVENT
- O plugin quer reagir mas não precisa influenciar o resultado do core.
- Só efeitos colaterais — enviar webhooks, escrever logs, dar pontos de fidelidade, despachar jobs de fila.
- O core já se comprometeu com a ação no momento em que o evento dispara; o listener não pode cancelar.
EVENT e a flag $keepData. O evento delete_plugin_* é o único lugar onde o args-bag carrega um sinal out-of-band. $keepData = true diz ao listener "o admin quer manter os dados deste plugin, pule migrate:rollback". O listener do esqueleto já respeita: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Plugins que não são donos de dados preserváveis podem ignorar o arg com o default.
BEHAVIOR — set() + perform()
Um callable é dono do comportamento nomeado. O host chama perform() para executar quem estiver registrado no momento. set() reivindica o nome exclusivamente — se um segundo caller tenta set no mesmo nome, HookManager::set() lança uma exceção imediatamente. Não há override silencioso; conflitos aparecem no boot, não em produção.
Mecânica
// Plugin overrides (in ServiceProvider::boot())
Hook::set('dispatch_list_import_job', fn($list, $file) =>
new \\AcmeCorp\\FastImport\\FasterImportJob($list, $file));
// Core registers default + executes (app/Http/Controllers/SubscriberController.php:433)
Hook::setIfEmpty('dispatch_list_import_job', function ($list, $filepath) use ($request) {
return new ImportJob($list, $filepath, $request);
});
$currentJob = Hook::perform('dispatch_list_import_job', [$list, $filepath]);
dispatch($currentJob);
A regra de timing do setIfEmpty
O default do host vai por setIfEmpty(), não por set() — a diferença é intencional. setIfEmpty só registra o callback se ninguém mais reivindicou o nome ainda; se um plugin já chamou set em seu boot(), o default do host é silenciosamente pulado.
Isso significa que setIfEmpty precisa ser colocado diretamente antes de perform(), no controller ou model, não em um service provider. A razão: quando o request handler do controller roda, todo ServiceProvider::boot() de plugin terminou — então qualquer override de plugin registrado por set já fez efeito. Colocar o default em register() ou boot() arriscaria rodar antes do set() do plugin e travar o plugin de fora.
Hooks BEHAVIOR reais no core
| Nome do hook | Onde o host registra default + perform | O que é sobrescrito |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | A classe job enfileirada quando um admin importa um CSV — plugins podem trocar por uma variante mais rápida, distribuída ou instrumentada |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | A URL de imagem renderizada para a entrada do plugin na página admin Plugins — default /images/plugin.svg; plugins chamam Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) em seu boot() |
Quando usar BEHAVIOR
- Exatamente um pedaço de lógica deve rodar.
- Um default razoável existe, mas um plugin deve poder trocá-lo completamente.
- Dois plugins reivindicando o mesmo comportamento é um bug, não uma feature — você quer que falhe alto.
Exclusividade BEHAVIOR é uma feature, não um problema. Se dois plugins precisam legitimamente influenciar o mesmo comportamento, a forma certa é REGISTRY (cada um contribui um candidato, o host escolhe um) ou FILTER (cada plugin transforma o valor por uma cadeia). Pegar BEHAVIOR para "lógica compartilhada" força uma corrida não-vencível entre dois autores de plugin. HookManager::set() lança com Behavior "{name}" has already been registered no momento em que o segundo plugin boota — então o conflito é impossível de entregar para produção.
FILTER — modify() + filter()
Um valor passa por uma cadeia de callbacks. Cada callback recebe o valor atual (mais quaisquer args posicionais extras) e retorna o valor para passar ao próximo. O host chama <code>filter()</code> com o valor inicial; o retorno é o valor final depois que cada plugin teve sua vez.
Mecânica
// Plugin (in ServiceProvider::boot())
Hook::modify('sidebar-menu-items', function (array $items) {
array_splice($items, 1, 0, [
['label' => 'Loyalty', 'url' => route('loyalty_points.dashboard'), 'icon' => 'star'],
]);
return $items;
});
// Core
$items = Hook::filter('sidebar-menu-items', $defaultItems);
Args posicionais opcionais
Hook::filter($name, $value, $extraParams) aceita um terceiro array de argumentos posicionais que são passados para cada callback junto com o valor atual. O exemplo de contrato do guia de plugin é o redirect do maillist:
// Plugin
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
if (MyPlugin::shouldRedirect($list, $request->user())) {
return redirect()->route('my_plugin.custom_page', $list->uid);
}
return $redirect; // null means "do not redirect"
});
// Core
$redirect = Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
return $redirect;
}
// continue rendering
Quando usar FILTER
- Um valor viaja por vários plugins antes do host usar.
- Cada plugin pode compor com o anterior — adicionar a um menu, modificar conteúdo de email, condicionar um redirect, transformar um payload.
- Retornar a entrada não modificada é o opt-out convencional — sem exceção, sem sinal especial.
FILTER é o padrão menos exercitado na base de código core atual. A implementação de HookManager é estável (linhas 143-159 do arquivo), e o contrato está documentado para autores de plugin, mas o core ainda não chama Hook::filter em hot paths de produção além do exemplo documentado page.maillist.show.redirect. Novos hot paths do lado do host que querem transforms componíveis por plugin devem reachar por FILTER em vez de BEHAVIOR — as semânticas de chain são exatamente o que a maioria das situações "quero que plugins possam adicionar a isso" precisa.
Semânticas de conflito entre os quatro padrões
Quando dois plugins miram no mesmo nome de hook, os quatro padrões reagem diferente. Saber qual tipo de padrão você tem diz exatamente como o conflito se parece e se ele aparecerá no boot ou muito depois.
| Padrão | O que dois plugins mirando no mesmo nome fazem | Como aparece |
| REGISTRY | Ambas as contribuições são mantidas; collect retorna as duas | Sem conflito — por design. Ordem é ordem de registro. |
| EVENT | Ambos os listeners rodam; efeitos colaterais compõem | Sem conflito — por design. Ordem é ordem de registro. |
| BEHAVIOR | A segunda chamada set lança com Behavior "{name}" has already been registered | Exceção em tempo de boot — o ServiceProvider::boot() do plugin falha, o master file grava o erro, a página admin Plugins mostra uma pill vermelha. Produção nunca vê o override silencioso. |
| FILTER | O valor passa por ambos os callbacks em ordem de registro | Sem conflito — por design. Cada callback pode opt-out retornando a entrada não modificada. |
Três dos quatro padrões são livres de conflito porque agregam. BEHAVIOR é o único com posse exclusiva, e o modo de falha é uma exceção dura no boot — uma escolha de design deliberada para que uso indevido não possa coexistir com um install funcionando.
A convenção do args-bag
Todo fire / collect / perform / filter tem a mesma forma: $name, $value (ou default), $params. O array $params é unpackado posicionalmente no callback registrado. Cada callback na cadeia recebe os mesmos args.
- Mantenha args-bags pequenos e estáveis. Uma vez que um hook entrega, os args viram um contrato — adicionar um arg posicional quebra todo plugin que já bindou a closure com assinatura fixa.
- Passe models, não bags de campo.
fire('customer_added', [$customer]) deixa o listener decidir quais campos ler; fire('customer_added', [$customer->email, $customer->uid, ...]) travaria os args nos campos que existiam quando o hook entregou.
- Use hooks adicionais em vez de sobrecarregar args. Se um slot precisa de contexto mais rico, registre uma chave de hook separada em vez de adicionar um quinto arg posicional opcional.
O quirk de collect por referência
Uma pequena fatia do core usa collect() com argumentos por referência como um hook de mutação — fora do modelo canônico de quatro padrões. O call-site filter_aws_ses_dns_records é o exemplo canônico:
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
O host dispara o hook esperando que plugins mutem $dkims e $spf in place. Tecnicamente isso é um uso indevido de REGISTRY — uma cadeia FILTER verdadeira teria sido a forma certa. O comportamento funciona porque closures PHP honram args por referência, mas autores de plugin não devem escrever novos call-sites nesse estilo. Pegue FILTER (valor único transformado) ou REGISTRY (múltiplas contribuições imutáveis).
Seis anti-padrões para evitar
Os padrões abaixo todos parecem certos à primeira vista e quebram de forma sutil. Cada um está ancorado em uma classe de bug já vista em plugins de produção.
1. Registrando hooks em register() quando precisam de boot()
O register() do host roda antes de qualquer boot() de service provider. Hooks registrados ali disparam antes de dependências conectadas pelo register() de outros providers. Sintoma: Class not found já na primeira requisição, antes de qualquer código de plugin executar seu caminho principal. Correção: só o hook add_translation_file pertence a register(); todo o resto vai em boot().
2. Pegar BEHAVIOR quando "compartilhado" é o objetivo
BEHAVIOR lança no segundo set. Se dois plugins precisam legitimamente influenciar o mesmo ponto, FILTER (compor) ou REGISTRY (coletar) são a forma certa. Correção: reescreva o contrato do hook — emita uma cadeia ou uma lista, não um callable exclusivo.
3. Colocar setIfEmpty em um service provider
setIfEmpty só faz efeito se ninguém mais se registrou ainda. Colocá-lo em register() ou boot() significa que ele pode rodar antes do set() de um plugin, travando o plugin de fora. Correção: coloque setIfEmpty diretamente acima da chamada perform correspondente, no controller ou model, para que todo boot() de plugin já tenha terminado.
4. Mutar estado compartilhado dentro de um callback REGISTRY
collect chama cada callback em ordem de registro. Um callback que escreve em um cache ou sessão compartilhado como efeito colateral torna o hook não-determinístico — rodando duas vezes com ordem de plugin diferente dá estado de cache diferente. Correção: mantenha callbacks add puros. Se a contribuição depende de efeitos colaterais, registre um listener EVENT separadamente.
5. Adicionar args posicionais a um hook existente
Uma vez que um hook está em produção, plugins já bindaram closures com a aridade original. Adicionar um quinto arg posicional quebra todo binding que omitiu. Correção: registre um novo nome de hook (customer_added_v2) e aceite uma janela de transição, ou passe o novo contexto pelo objeto model que já está no args-bag.
6. Usar collect() para uma única resposta
REGISTRY retorna um array — mesmo quando só um plugin se registra, o host recebe [$result]. Tratar isso como a resposta ($first = Hook::collect(...)[0]) silenciosamente pega o plugin que bootou primeiro, sem semântica de conflito. Correção: use BEHAVIOR se exatamente uma resposta é esperada.
Como escolher um padrão
A decisão geralmente colapsa em quatro perguntas. Respondê-las em ordem escolhe a forma certa:
- Múltiplos plugins devem compor? Se sim, REGISTRY (contribuições independentes) ou FILTER (transform encadeado). Se não, BEHAVIOR (override exclusivo).
- O host precisa de um valor de retorno? Se não, EVENT (só efeitos colaterais). Se sim, REGISTRY / BEHAVIOR / FILTER.
- O valor passa por várias mãos? Se sim, FILTER (chain). Se cada mão contribui independentemente, REGISTRY (collect).
- Conflito entre dois plugins é um bug? Se sim, BEHAVIOR (falha alta no boot). Se não, os padrões livres de conflito.
Em dúvida, pegue o padrão mais frouxo. REGISTRY mais um EVENT "out of band" para limpeza é quase sempre mais flexível que BEHAVIOR — as semânticas de conflito é o que mata BEHAVIOR na prática, e os padrões frouxos deixam plugins comporem sem coordenação.
Para onde ir em seguida
Você agora tem os quatro padrões em profundidade e as semânticas de conflito que tornam cada forma previsível. Três páginas transformam esse conhecimento nas superfícies de uso diário que um plugin real vai de fato tocar: