Monte un chatbox, un grupo de barra lateral o una tarjeta de página, sin bifurcar una Blade.

La aplicación host reserva cuatro tipos de slot para que los plugins rendericen dentro: tres slots a nivel de layout que se disparan en cada página que extiende los layouts maestros app / admin, más un slot por página para inyección más granular. Los cuatro usan el patrón REGISTRY: cada plugin que se registra aporta una cadena HTML (o null para saltarse) y el host itera el array, filtra los retornos falsy y emite cada fragmento sin escapar. Esta página cubre el contrato, la bolsa de args, las implementaciones canónicas de acelle/ai y los antipatrones que parecen correctos pero se rompen en producción.

Por qué existe la inyección de UI

Un plugin que añade una funcionalidad normalmente necesita mostrarla en algún sitio de la UI del host. Las opciones ingenuas son malas de formas distintas: bifurcar los layouts Blade del host obliga al plugin a mantener su propia copia a través de cada actualización del host; parchearlos al instalar deja el código fuente del host desincronizado con lo que corre en producción. Las dos atan al plugin y al host a una carga de mantenimiento que crece con cada release.

El sistema de plugins evita esa contrapartida reservando slots con nombre dentro de los layouts maestros del host. Cada slot es un hook REGISTRY: cada plugin que se registra aporta una cadena HTML, el host los recoge en el momento del render, filtra las contribuciones falsy y emite cada fragmento en el orden de registro. Los plugins nunca ven el código Blade del host; el host nunca sabe qué plugins aportaron qué.

Los tres slots de layout

Tres hooks REGISTRY se disparan desde los layouts maestros app y admin. Juntos cubren casi toda extensión de UI que necesitará alguna vez un plugin: assets en la cabecera del documento, widgets al final del body y grupos de la barra lateral de administración.

Clave del hookDónde vive el call-siteBolsa de argsPara qué se usa
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php, justo antes de @yield('head') [$layout, $context] Etiquetas <link> / <style> / <script> que deben cargarse antes del contenido específico de la página: CSS del chatbox, scripts del popover de Sparkle
layout.body.before_close Los mismos archivos, justo antes de </body> [$layout, $context] Widgets flotantes que se montan una vez por página: burbuja del chatbox, overlays modales, popovers de Sparkle
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (sin args) Secciones de la barra lateral de administración aportadas por plugins: cada entrada se renderiza como un fragmento <div class="mc-nav-group">...</div>

Los tres se recogen con el mismo idiom en el lado del host. El snippet Blade que viene en resources/views/refactor/layouts/admin.blade.php es así:

@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
    {!! $html !!}
@endforeach

Tres cosas que leer de ese snippet: collect recibe una bolsa de args (aquí ['admin']), array_filter descarta toda contribución null / false / '', y el HTML superviviente se emite con {!! !!}: sin escapar, porque ya es Blade renderizada.

El contrato — devuelva HTML o null

Un callback REGISTRY para cualquiera de los tres slots de layout devuelve una de dos cosas:

  1. Una cadena HTML: normalmente el resultado de view('myname::partials.foo')->render(). El host la emite tal cual con {!! !!}.
  2. null (o cualquier valor falsy: false, '', 0). El array_filter del host lo descarta. Esta es la forma convencional de controlar una contribución mediante un feature flag, el estado del plugin, el entorno o el contexto por petición.

Devolver null es preferible a no registrarse. El boot() del plugin se ejecuta una vez por proceso; decidir si contribuir debería ocurrir en cada render, no en el arranque. La comprobación aiPluginAvailable() en storage/app/plugins/acelle/ai/src/ServiceProvider.php:732 es el ejemplo canónico: el closure cortocircuita a null cada vez que el módulo de IA está bloqueado, dejando intactas las contribuciones de los demás plugins.

El HTML devuelto debe ser autocontenido. El host coloca el fragmento en el documento en el call-site sin escapado ni envoltorio adicional. Cualquier cosa de la que dependa el fragmento (CSS, JS, fuentes) tiene que estar ya cargada en el momento del renderizado. Por eso existe layout.head.assets además de layout.body.before_close: los fragmentos de head cargan primero, los de body se montan al final, y el plugin puede dividir su registro de assets entre ambos slots cuando lo necesite.

La bolsa de args — $layout y $context

layout.head.assets y layout.body.before_close pasan ambos dos args posicionales: $layout (una cadena que identifica qué layout maestro disparó el slot: 'app', 'admin', etc.) y $context (un array opcional con props específicas de la superficie que el layout elige exponer).

// 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();
});

La única clave de hook compartida se dispara desde cada layout maestro: app, admin, el constructor de email, el constructor de formularios, el editor de automatización. El partial del plugin despacha internamente sobre $layout para renderizar el conjunto de assets o la configuración de chatbox correctos. No hay hooks separados layout.app.head.assets / layout.admin.head.assets; el nombre del layout es solo un discriminador dentro de una sola bolsa compartida.

Añadir más args posicionales a un slot de layout existente rompería cada plugin que ya hubiera vinculado un closure con la aridad original. El contexto nuevo pertenece al array $context (que puede crecer sin cambiar la firma del closure) o detrás de una clave de hook separada. Las propias contribuciones aiHooks del host manejan esto exactamente: el constructor y el editor de automatización pasan props de superficie a través de $context, y el plugin lee $context['kind'], $context['task'], etc., cuando están presentes.

Ejemplo trabajado — la burbuja del chatbox de acelle/ai

La referencia canónica para la inyección a nivel de layout vive en storage/app/plugins/acelle/ai/src/ServiceProvider.php, líneas 678-728. El plugin contribuye a los tres slots de layout, cada uno detrás de la misma protección aiPluginAvailable(). El bloque completo de registro, parafraseado al contrato de arriba:

// 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();
    });
}

Lo que aterriza en producción desde esos tres bloques: cada página que extiende el layout app o admin obtiene el CSS / JS del chatbox en el <head>, el HTML de la burbuja del chatbox antes del </body> y (solo en las páginas de administración) un grupo «AI» de barra lateral renderizado después de los grupos integrados del host. Ningún archivo Blade del host se modificó para que nada de eso funcionara: el plugin aporta a través de las claves de hook compartidas y el host renderiza lo que aterriza.

El plugin controla la contribución con aiPluginAvailable(), que comprueba ai_plugin_active(): un helper que en última instancia resuelve a Plugin::getByName('acelle/ai')->isActive(). Cuando un admin desactiva el plugin desde la página de Plugins de administración, cada callback devuelve null en la siguiente petición y la UI del chatbox + Sparkle desaparece, sin recargar rutas, descargar servicios registrados ni invalidar ninguna caché.

admin.sidebar.groups es el más simple de los tres hooks de layout: sin args, el callback devuelve un fragmento autocontenido <div class="mc-nav-group">...</div>. El host lo renderiza después de los grupos integrados (Customers, Plans, Settings, ...) y antes de cualquier cierre de layout. El orden es el orden de registro, así que los plugins que necesiten ganar posición de render deben registrarse tarde en boot(), después de las dependencias.

El grupo de barra lateral de acelle/ai vive en resources/views/partials/admin_sidebar_group.blade.php dentro del plugin y renderiza un grupo «AI» con tres o cuatro hijos según los flags de plan. El mismo patrón funciona para cualquier plugin que necesite una sección de administración de nivel superior: un plugin de puntos de fidelidad, un plugin de pasarela de pago, un plugin de driver de envío regional.

Slots a nivel de página — page.{controller}.{action}.{slot}

La inyección a nivel de layout cubre lo que debe aparecer en cada página; los slots a nivel de página cubren lo que debe aparecer en una concreta. La convención de nombres hace explícito el vínculo:

page.{controller_slug}.{action}.{slot}

Ejemplos:

  • page.maillist.show.body: tarjetas extra renderizadas dentro del cuerpo de la página de detalle de la lista de correo
  • page.maillist.verification.body: contenido renderizado sobre el bloque de estado de verificación
  • page.campaign.index.sidebar: adiciones a la barra lateral de la página de índice de campañas
  • page.customer.edit.footer: adiciones al pie de la página de edición de cliente

El call-site del host se ve igual que el de los slots de layout: collect, array_filter, emitir cada fragmento con {!! !!}:


@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
    {!! $html !!}
@endforeach

Y la contribución del plugin se ve igual que la de un slot de layout, con la bolsa de args específica del slot pasada a través:

// 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();
});

Las bolsas de args para los slots a nivel de página suelen llevar el modelo relevante (la lista de correo, la campaña, el cliente) para que el plugin pueda leer lo que necesite sin una consulta extra a la base de datos. Seguir esa convención mantiene estable la firma del hook a través de las evoluciones del modelo del host: añadir un campo nuevo a MailList no rompe la firma del hook de ningún plugin, porque el plugin siempre recibe el modelo.

Redirecciones de página — la variante FILTER

Un puñado de hooks de página usan el patrón FILTER en lugar de REGISTRY: el caso típico es «deja que los plugins decidan si el usuario debería ser redirigido antes de que el core renderice». El 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"
});

La forma es FILTER (transformación encadenada de un valor) en lugar de REGISTRY (varias contribuciones independientes) porque solo puede ocurrir una redirección por petición. Devolver el input sin cambios desde el closure es el opt-out convencional: el siguiente plugin de la cadena ve lo que el anterior decidió. El primer valor no nulo gana porque el controlador comprueba if ($redirect) después de que la cadena de filtro termine.

Este patrón es el que usa athena/evs para enrutar al usuario a su propia página de verificación cuando el plugin de verificación de email necesita tomar el control de la superficie de verificación de la lista de correo. La mecánica completa de FILTER vive en el análisis a fondo del sistema de Hooks; la conclusión práctica aquí es que las redirecciones a nivel de página usan FILTER, mientras que el renderizado a nivel de página usa REGISTRY.

Publicación de assets — empaquetando CSS / JS / imágenes con el plugin

Los slots de layout son sobre dónde renderiza un plugin. La publicación de assets es sobre qué puede referenciar el HTML renderizado. Los plugins que entregan CSS, JavaScript, fuentes o imágenes usan la API estándar de Laravel publishes(), con la etiqueta 'plugin' que el host conoce:

// In ServiceProvider::boot()
$this->publishes([
    __DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');

En cada Plugin::register(), el host ejecuta artisan vendor:publish --tag=plugin --force, que copia el árbol resources/assets/ del plugin a public/plugins/{vendor}/{name}/. Los propios partials del plugin referencian los assets a través de esa ruta:


<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">

La ruta que sirve el icono desde routes.php (la ruta con nombre plugin.{vendor}.{name}.icon) es la alternativa: en lugar de publicar un SVG estático en public/, el plugin puede exponer una ruta HTTP que stremea su icono directamente desde storage/app/plugins/{vendor}/{name}/icon.svg. La contrapartida: la ruta publicada es más rápida (cacheable por CDN) pero exige una publicación en cada instalación; la ruta enrutada es autocontenida pero paga un arranque de Laravel por petición.

Antipatrones

1. Devolver un objeto View de Blade en lugar de una cadena

El host emite lo que devuelva el closure con {!! !!}. Devolver view('foo') emite el __toString del objeto, lo que funciona la mayoría del tiempo pero pierde la oportunidad de que el closure gestione errores de render con elegancia. Solución: llame siempre a ->render() y devuelva la cadena resultante, exactamente como hacen los registros de acelle/ai.

2. Olvidar la rama de control null

Un callback REGISTRY que siempre devuelve HTML sigue aportando esté el plugin activo o no, porque autoloadWithoutDbQuery() carga también los plugins inactivos (vea Arquitectura de plugins § Por qué los plugins inactivos siguen afectando a la app). Solución: proteja con Plugin::enabled('myvendor/myplugin') o con un helper de feature flag en la parte superior de cada closure, y devuelva null cuando esté bloqueado.

3. Llamar a collect() con args extra que el host no pasó

Los plugins no llaman a Hook::collect por su cuenta: lo hace el host. Si un plugin necesita el nombre del layout para una decisión a medida, lo lee del primer arg del closure. Intentar hacer Hook::collect de un slot de layout desde dentro de un plugin ejecuta el callback de cada uno de los demás plugins una vez extra. Solución: el closure recibe todos los args que necesita; nunca vuelva a invocar collect desde dentro de un handler registrado.

4. Renderizar trabajo bloqueante dentro del closure

El closure se ejecuta una vez por render de página: una llamada a la API de Stripe o una consulta a BD de 200 ms dentro de él añade ese coste a cada petición. Solución: precompute, cachee o muévalo a un loader asíncrono. El fragmento puede devolver un placeholder <div data-async-loader> que el JS del plugin hidrata desde un fetch en segundo plano.

5. Efectos secundarios en un callback REGISTRY

collect llama a cada callback en el orden de registro. Un callback que escribe en una sesión, caché o log como efecto secundario hace que el hook no sea determinista. Dos plugins podrían correr una carrera para mutar la misma clave. Solución: mantenga puros los callbacks de add: existen para aportar un valor, no para hacer trabajo. Si necesita un efecto secundario, registre un listener EVENT en un hook aparte.

6. Ámbitos de CSS solapados

Dos plugins inyectan ambos CSS a través de layout.head.assets; los dos definen una clase llamada .mc-popover. El orden en que se cargan los plugins es el orden en que aterriza su CSS, y gana el último. Solución: ponga namespace a las clases CSS del plugin (.acmecorp-loyalty-popover), o limite el ámbito con un selector de atributo en un elemento envoltorio. El host no vigila el CSS de los plugins: eso es disciplina del autor del plugin.

A dónde ir después

La inyección de UI es la superficie más preguntada por los autores de plugin nuevos, pero rara vez es toda la funcionalidad. Tres páginas llevan el mismo conjunto de herramientas más lejos: