Por que injeção de UI existe
Um plugin que adiciona uma feature normalmente precisa expor essa feature em algum lugar na UI do host. As opções ingênuas são ruins de jeitos diferentes: dar fork nos Blade layouts do host força o plugin a manter sua própria cópia através de todo upgrade do host; patchear no install time deixa a fonte do host fora de sync com o que roda em produção. Os dois travam o plugin e o host num fardo de manutenção que cresce com cada release.
O sistema de plugins evita esse trade-off reservando slots nomeados dentro dos layouts master do host. Cada slot é um hook REGISTRY — todo plugin que registra contribui uma string HTML, o host coleta no render time, filtra as contribuições falsy e emite cada fragmento na ordem de registro. Plugins nunca veem a fonte Blade do host; o host nunca sabe qual plugin contribuiu o quê.
Os três slots de layout
Três hooks REGISTRY disparam dos layouts master app e admin. Juntos eles cobrem quase toda extensão de UI que um plugin vai precisar — assets head-of-document, widgets body-end e grupos de sidebar admin.
| Hook key | Onde o call site vive | Args bag | Usado para |
layout.head.assets |
resources/views/refactor/layouts/{app,admin}.blade.php, logo antes de @yield('head') |
[$layout, $context] |
Tags <link> / <style> / <script> que precisam carregar antes do conteúdo page-specific — CSS do chatbox, scripts do popover sparkle |
layout.body.before_close |
Mesmos arquivos, logo antes de </body> |
[$layout, $context] |
Widgets flutuantes que montam uma vez por página — bubble de chatbox, modal overlays, popovers sparkle |
admin.sidebar.groups |
resources/views/refactor/components/nav/admin-sidebar.blade.php |
(sem args) |
Seções de sidebar admin contribuídas por plugin — toda entrada renderiza como um fragmento <div class="mc-nav-group">...</div> |
Os três são coletados através do mesmo idiom no lado do host. O snippet Blade que vem em resources/views/refactor/layouts/admin.blade.php lê:
@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
{!! $html !!}
@endforeach
Três coisas para ler desse snippet: collect aceita um args bag (aqui ['admin']), array_filter derruba toda contribuição null / false / '', e o HTML sobrevivente é emitido com {!! !!} — sem escape, porque já é Blade renderizado.
O contrato — retorne HTML ou null
Uma callback REGISTRY para qualquer dos três slots de layout retorna uma de duas coisas:
- Uma string HTML — tipicamente o resultado de
view('myname::partials.foo')->render(). O host emite verbatim com {!! !!}.
null (ou qualquer valor falsy — false, '', 0). O array_filter do host derruba. Esse é o jeito convencional de gatear uma contribuição por feature flag, status do plugin, environment ou contexto per-request.
Retornar null é preferido sobre não se registrar de jeito nenhum. O boot() do plugin roda uma vez por processo; decidir se contribuir deve acontecer em todo render, não no boot. A checagem aiPluginAvailable() em storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 é o exemplo canônico — a closure dá curto-circuito para null sempre que o módulo AI está gateado off, deixando toda outra contribuição de plugin intocada.
HTML retornado precisa ser autocontido. O host larga o fragmento no documento no call site sem escape ou wrapper adicional. Qualquer coisa que o fragmento dependa — CSS, JS, font files — tem que já estar carregado quando o rendering acontece. É por isso que layout.head.assets existe além de layout.body.before_close: fragmentos de head carregam primeiro, fragmentos de body montam por último e o plugin pode dividir o registro de assets entre os dois slots quando precisa.
O args-bag — $layout e $context
layout.head.assets e layout.body.before_close ambos passam dois args posicionais: $layout (uma string identificando qual layout master disparou o slot — 'app', 'admin', etc.) e $context (um array opcional carregando props surface-specific que o layout escolhe expor).
// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
A única hook key compartilhada dispara de todo layout master — app, admin, o email builder, o form builder, o automation editor. O partial do plugin despacha internamente em $layout para renderizar o conjunto certo de assets ou configuração de chatbox. Não há hook separado layout.app.head.assets / layout.admin.head.assets; o nome do layout é só um discriminador dentro de um args bag compartilhado.
Adicionar mais args posicionais a um slot de layout existente quebraria todo plugin que já bindou uma closure com a aridade original. Contexto novo pertence ao array $context (que pode crescer sem mudar a assinatura da closure), ou atrás de uma hook key separada. As próprias contribuições aiHooks do host lidam com isso exatamente — o builder e o automation editor passam props de superfície via $context, e o plugin lê $context['kind'], $context['task'], etc., quando presentes.
Exemplo trabalhado — o bubble de chatbox do acelle/ai
A referência canônica para injeção de layout vive em storage/app/plugins/acelle/ai/src/ServiceProvider.php, linhas 678-728. O plugin contribui para os três slots de layout, cada um atrás do mesmo gate aiPluginAvailable(). O bloco de registro completo, parafraseado para o contrato acima:
// In acelle/ai's ServiceProvider::boot()
private function registerLayoutInjections(): void
{
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.head_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.body_assets', [
'layout' => $layout,
'context' => $context,
])->render();
});
}
private function registerAdminSidebarSection(): void
{
Hook::add('admin.sidebar.groups', function () {
if (! $this->aiPluginAvailable()) {
return null;
}
return view('ai::partials.admin_sidebar_group')->render();
});
}
O que cai em produção desses três blocos: toda página que estende o layout app ou admin pega CSS / JS do chatbox em <head>, o HTML do bubble de chatbox antes de </body>, e (em páginas admin só) um grupo de sidebar "AI" renderizado depois dos grupos built-in do host. Nenhum dos arquivos Blade do host foi modificado para fazer nada disso funcionar — o plugin contribui pelas hook keys compartilhadas e o host renderiza o que cair.
O plugin gateia a contribuição com aiPluginAvailable(), que checa ai_plugin_active() — um helper que em última instância resolve para Plugin::getByName('acelle/ai')->isActive(). Quando um admin desabilita o plugin pela página admin Plugins, toda callback retorna null na próxima request, e a UI do chatbox + sparkle desaparece — sem recarregar routes, dropar services registrados ou invalidar nenhum cache.
admin.sidebar.groups é o mais simples dos três hooks de layout: sem args, a callback retorna um fragmento autocontido <div class="mc-nav-group">...</div>. O host renderiza depois dos grupos built-in (Customers, Plans, Settings, ...) e antes de qualquer layout de fechamento. Ordem é ordem de registro, então plugins que precisam ganhar posição de render devem se registrar tarde no boot() depois das dependências.
O grupo de sidebar do acelle/ai vive em resources/views/partials/admin_sidebar_group.blade.php dentro do plugin e renderiza um grupo "AI" com três ou quatro filhos dependendo de flags de plano. O mesmo padrão funciona para qualquer plugin que precise de uma seção admin top-level — um plugin de loyalty-points, um plugin de payment-gateway, um plugin de sending-driver regional.
Slots nível-página — page.{controller}.{action}.{slot}
Injeção nível-layout cobre o que deve aparecer em toda página; slots nível-página cobrem o que deve aparecer em uma específica. A convenção de nomenclatura torna o binding explícito:
page.{controller_slug}.{action}.{slot}
Exemplos:
page.maillist.show.body — cards extras renderizados dentro do body da página de detalhe da mail-list
page.maillist.verification.body — conteúdo renderizado acima do bloco de status de verificação
page.campaign.index.sidebar — adições de sidebar na página index de campanha
page.customer.edit.footer — adições de footer na página de edit de customer
O call site do host parece o mesmo que os slots de layout — collect, array_filter, emit cada fragmento com {!! !!}:
@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
{!! $html !!}
@endforeach
E a contribuição do plugin parece exatamente como uma de layout-slot, com o args bag slot-specific passado adiante:
// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
$points = LoyaltyPoints::getTotalForList($list);
return view('loyalty::list_points_card', [
'list' => $list,
'points' => $points,
])->render();
});
Args bags para slots nível-página tendem a carregar o model relevante — a mail list, a campanha, o customer — para que o plugin possa ler o que precisar sem uma query extra de banco. Seguir essa convenção mantém a assinatura do hook estável através de evoluções de model do host: adicionar um novo field ao MailList não quebra a assinatura de hook de nenhum plugin, porque o plugin sempre recebe o model.
Redirects de página — a variante FILTER
Um punhado de hooks de página usa o padrão FILTER em vez de REGISTRY — o caso típico é "deixe plugins decidirem se o usuário deve ser redirecionado antes do core renderizar". O contrato:
// Core controller
$redirect = \App\Library\Facades\Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
return $redirect;
}
// continue rendering the page
// Plugin (in ServiceProvider::boot())
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"
});
A shape é FILTER (transformação encadeada de um valor) em vez de REGISTRY (múltiplas contribuições independentes) porque só um redirect pode acontecer por request. Retornar o input inchanged da closure é o opt-out convencional — o próximo plugin na cadeia vê o que o anterior decidiu. O primeiro valor não-null ganha porque o controller checa if ($redirect) depois que a cadeia de filter termina.
Esse padrão é o que athena/evs usa para rotear o usuário para sua própria página de verificação quando o plugin de verificação de email precisa tomar conta da superfície de verificação da maillist. A mecânica completa de FILTER vive no deep-dive do sistema de Hook; o takeaway prático aqui é que redirects nível-página usam FILTER, enquanto rendering nível-página usa REGISTRY.
Publicação de assets — empacotando CSS / JS / imagens com o plugin
Slots de layout são sobre onde um plugin renderiza. Publicação de assets é sobre o que o HTML renderizado pode referenciar. Plugins que entregam CSS, JavaScript, fonts ou imagens usam a API padrão publishes() do Laravel, com a tag 'plugin' que o host conhece:
// In ServiceProvider::boot()
$this->publishes([
__DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');
Em todo Plugin::register(), o host roda artisan vendor:publish --tag=plugin --force, que copia a árvore resources/assets/ do plugin para public/plugins/{vendor}/{name}/. Os próprios partials do plugin referenciam assets por esse caminho:
<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">
A route que serve ícone do routes.php (a named route plugin.{vendor}.{name}.icon) é a alternativa — em vez de publicar um SVG estático em public/, o plugin pode expor uma route HTTP que streama seu ícone direto de storage/app/plugins/{vendor}/{name}/icon.svg. O trade-off: o caminho publicado é mais rápido (CDN-cacheable) mas exige um publish em todo install; o caminho roteado é autocontido mas paga um boot do Laravel por request.
Anti-padrões
1. Retornar um objeto View Blade em vez de uma string
O host emite o que a closure retornar com {!! !!}. Retornar view('foo') emite o __toString do objeto, que funciona na maior parte do tempo mas perde a chance da closure lidar graciosamente com erros de render. Fix: sempre chame ->render() e retorne a string resultante, exatamente como os registros do acelle/ai fazem.
2. Esquecer o branch de gate null
Uma callback REGISTRY que sempre retorna HTML continua contribuindo esteja o plugin ativo ou não — porque autoloadWithoutDbQuery() carrega plugins inativos também (veja Arquitetura de plugin § Por que plugins inativos ainda afetam a app). Fix: proteja com Plugin::enabled('myvendor/myplugin') ou um helper de feature-flag no topo de toda closure, retorne null quando gateado off.
3. Chamar collect() com args extras que o host não passou
Plugins não chamam Hook::collect por conta própria — o host chama. Se um plugin precisa do nome do layout para uma decisão custom, lê do primeiro arg da closure. Tentar Hook::collect num slot de layout de dentro de um plugin roda toda callback de todo outro plugin uma vez extra. Fix: a closure recebe todos os args que precisa; nunca re-invoque collect de dentro de um handler registrado.
4. Renderizar trabalho bloqueante dentro da closure
A closure roda uma vez por render de página — uma chamada API Stripe ou uma query DB de 200ms dentro adiciona esse custo a toda request. Fix: pré-compute, cache ou mova para um loader assíncrono. O fragmento pode retornar um placeholder <div data-async-loader> que o JS do plugin hidrata de um fetch em background.
5. Side effects em uma callback REGISTRY
collect chama toda callback em ordem de registro. Uma callback que escreve em sessão, cache ou log como side effect torna o hook não-determinístico. Dois plugins podem competir para mutar a mesma key. Fix: mantenha callbacks add puras — elas existem para contribuir um valor, não para fazer trabalho. Se você precisa de side effect, registre um listener EVENT num hook separado.
6. Escopos CSS sobrepostos
Dois plugins ambos injetam CSS via layout.head.assets; ambos definem uma classe chamada .mc-popover. A ordem em que plugins são carregados é a ordem em que seu CSS cai, e last-wins. Fix: faça namespace de classes CSS de plugin (.acmecorp-loyalty-popover), ou faça scope com um seletor de atributo num elemento wrapper. O host não policia CSS de plugin — essa é a disciplina do autor de plugin.
Para onde ir em seguida
Injeção de UI é a superfície mais perguntada por novos autores de plugin, mas raramente é a feature inteira. Três páginas levam o mesmo toolkit adiante: