El mapa de los cuatro patrones
Cada hook del código cae exactamente en una de cuatro formas. La forma determina la semántica de conflictos, el tratamiento del valor de retorno y qué tipo de interacción tiene sentido entre el core y los plugins. Recurrir al patrón equivocado aparece después como un caso límite extraño: saber cuál es cuál de antemano ahorra reescribir la integración.
| Patrón | Registrar | Ejecutar | Devuelve | Conflicto |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | array con el valor de retorno de cada callback | Fusión: cada callback se ejecuta, cada resultado se conserva |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | nada: los valores de retorno se descartan | Se disparan todos: cada listener se ejecuta y los efectos secundarios se componen |
| BEHAVIOR | Hook::set($name, $cb) o setIfEmpty | Hook::perform($name, [...args]) | lo que devuelva el único callback registrado | Exclusivo: un segundo set sobre el mismo nombre lanza una excepción de inmediato |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | el valor, transformado por cada callback en el orden de registro | Cadena: cada callback recibe la salida del anterior |
Un modelo mental útil: REGISTRY responde a «¿qué quiere aportar?»; EVENT responde a «¿quería enterarse?»; BEHAVIOR responde a «¿cómo debo hacerlo?»; FILTER responde a «¿en qué debe convertirse esto?». Las cuatro secciones siguientes cubren cada uno en profundidad, con los call-sites reales que vienen en el core.
REGISTRY — add() + collect()
Un plugin aporta uno o varios elementos a una lista con nombre. El host llama a collect() para obtener un array con cada contribución. Cada callback se ejecuta y cada valor de retorno se captura, en el orden 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 reales en el core
| Clave del hook | Dónde lo recoge el host | Qué aportan los plugins |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | Metadatos de la clase del driver: type, driver (FQCN), label |
register_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:120 | Metadatos del formulario de selección «Añadir servidor»: icono, nombre, descripción, create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | Nombres de los campos del formulario de configuración por driver: ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | Descriptor del archivo de traducción: carpeta de locale, prefijo, archivo maestro |
captcha_method | app/Model/Setting.php:290 | Metadatos del proveedor de captcha: id, label, closure de renderizado |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | Fragmentos HTML de banner que se muestran sobre el diálogo de importación de listas |
generate_big_notice_for_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:235 | Banners HTML para la página de detalle del servidor de envío |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | Cadenas de CSS / JS inyectadas antes de @yield('head') |
layout.body.before_close | Same files, before </body> | HTML de widgets flotantes: burbujas de chatbox, modales, popovers de Sparkle |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Secciones de la barra lateral de administración aportadas por plugins |
Cuándo usar REGISTRY
- Varios plugins podrían aportar (drivers de envío, métodos de captcha, elementos de la barra lateral).
- El host quiere todas las contribuciones, no solo la última.
- Las contribuciones se componen sin conflicto: elementos de lista, entradas de menú, fragmentos de banner, metadatos de configuración.
Convención de nombres
Dentro del código, los nombres de REGISTRY tienden a leerse como una frase verbal que describe la contribución: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Los nombres con verbo en singular (register_*, add_*) sugieren «aporte uno de estos», pero la mecánica de collect() sigue recogiendo todas las contribuciones: en el lado del registro no hay nada que limite a un plugin a una sola entrada.
EVENT — on() + fire()
Un plugin reacciona cuando ocurre algo en el host. Los valores de retorno se descartan: el contrato va en una sola dirección — el core notifica y los plugins componen efectos secundarios. Cada listener se ejecuta, en el orden 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 reales disparados por el core
| Nombre del hook | Dónde se dispara | Bolsa de args |
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 a través de 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 | (sin args) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData]: por defecto false |
Cuándo usar EVENT
- El plugin quiere reaccionar pero no necesita influir en el resultado del core.
- Solo efectos secundarios: enviar webhooks, escribir logs, conceder puntos de fidelidad, despachar jobs de cola.
- El core ya se ha comprometido con la acción para cuando se dispara el evento; el listener no puede cancelarla.
EVENT y el flag $keepData. El evento delete_plugin_* es el único sitio donde la bolsa de args lleva una señal fuera de banda. $keepData = true le dice al listener: «el admin quiere conservar los datos de este plugin, sáltate migrate:rollback». El listener del esqueleto ya lo respeta: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Los plugins que no poseen datos preservables pueden ignorar el argumento con el valor por defecto.
BEHAVIOR — set() + perform()
Un solo callable es dueño del comportamiento con nombre. El host llama a perform() para ejecutar a quien esté registrado en ese momento. set() reclama el nombre en exclusiva: si un segundo llamante intenta hacer set sobre el mismo nombre, HookManager::set() lanza una excepción de inmediato. No hay sobrescritura silenciosa; los conflictos afloran en el arranque, no en producción.
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);
La regla de tiempo de setIfEmpty
El valor por defecto del host pasa por setIfEmpty(), no por set(): la diferencia es intencional. setIfEmpty solo registra el callback si nadie más ha reclamado todavía el nombre; si un plugin ya llamó a set en su boot(), el valor por defecto del host se omite silenciosamente.
Eso significa que setIfEmpty debe colocarse justo antes de perform(), en el controlador o el modelo, y no en un service provider. La razón: para cuando se ejecuta el manejador de la petición en el controlador, el ServiceProvider::boot() de cada plugin ha terminado, así que cualquier sobrescritura registrada por un plugin con set ya ha surtido efecto. Colocar el valor por defecto en register() o boot() correría el riesgo de ejecutarse antes del set() del plugin y dejarlo fuera.
Hooks BEHAVIOR reales en el core
| Nombre del hook | Dónde registra el host el valor por defecto + perform | Qué se sobrescribe |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | La clase de job que se encola cuando un admin importa un CSV: los plugins pueden intercambiarla por una variante más rápida, distribuida o instrumentada |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | La URL de la imagen renderizada para la entrada del plugin en la página de Plugins de administración. Por defecto es /images/plugin.svg; los plugins llaman a Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) en su boot() |
Cuándo usar BEHAVIOR
- Exactamente una pieza de lógica debe ejecutarse.
- Existe un valor por defecto razonable, pero un plugin debería poder sustituirlo por completo.
- Que dos plugins reclamen el mismo comportamiento es un bug, no una funcionalidad: usted quiere que falle ruidosamente.
La exclusividad de BEHAVIOR es una funcionalidad, no un problema. Si dos plugins necesitan influir legítimamente en el mismo comportamiento, la forma correcta es REGISTRY (cada uno aporta un candidato, el host elige uno) o FILTER (cada plugin transforma el valor en una cadena). Recurrir a BEHAVIOR por «lógica compartida» fuerza una carrera imposible de ganar entre dos autores de plugin. HookManager::set() lanza una excepción con Behavior "{name}" has already been registered en el momento en que arranca el segundo plugin, así que el conflicto es imposible de llevar a producción.
FILTER — modify() + filter()
Un valor pasa por una cadena de callbacks. Cada callback recibe el valor actual (más cualquier args posicional extra) y devuelve el valor a pasar al siguiente. El host llama a <code>filter()</code> con el valor inicial; el retorno es el valor final después de que cada plugin haya tenido su turno.
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 posicionales opcionales
Hook::filter($name, $value, $extraParams) acepta un tercer array de argumentos posicionales que se pasan a cada callback junto al valor actual. El ejemplo del contrato de la guía de plugins es la redirección de la 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
Cuándo usar FILTER
- Un valor viaja por varios plugins antes de que el host lo use.
- Cada plugin puede componer con el anterior: añadir a un menú, modificar el contenido de un email, condicionar una redirección, transformar un payload.
- Devolver el input sin cambios es el opt-out convencional: sin excepción, sin señal especial.
FILTER es el patrón menos ejercitado en el código actual del core. La implementación de HookManager es estable (líneas 143-159 del archivo) y el contrato está documentado para los autores de plugin, pero el core aún no llama a Hook::filter en rutas calientes de producción más allá del ejemplo documentado de page.maillist.show.redirect. Las nuevas rutas calientes del lado del host que quieran transforms componibles por plugins deberían recurrir a FILTER antes que a BEHAVIOR: la semántica de cadena es exactamente lo que necesitan la mayoría de las situaciones de «quiero que los plugins puedan añadir a esto».
Semántica de conflictos en los cuatro patrones
Cuando dos plugins apuntan al mismo nombre de hook, los cuatro patrones reaccionan de forma distinta. Saber qué tipo de patrón tiene le dice exactamente cómo se ve un conflicto, y si va a aflorar en el arranque o mucho después.
| Patrón | Qué hacen dos plugins que apuntan al mismo nombre | Cómo aflora |
| REGISTRY | Se conservan ambas contribuciones; collect devuelve las dos | Sin conflicto, por diseño. El orden es el orden de registro. |
| EVENT | Se ejecutan ambos listeners; los efectos secundarios se componen | Sin conflicto, por diseño. El orden es el orden de registro. |
| BEHAVIOR | La segunda llamada a set lanza una excepción con Behavior "{name}" has already been registered | Excepción en el arranque: el ServiceProvider::boot() del plugin falla, el archivo maestro registra el error y la página de Plugins de administración muestra una píldora roja. Producción nunca ve la sobrescritura silenciosa. |
| FILTER | El valor pasa por ambos callbacks en el orden de registro | Sin conflicto, por diseño. Cada callback puede excluirse devolviendo el input sin cambios. |
Tres de los cuatro patrones están libres de conflicto porque agregan. BEHAVIOR es el único con propiedad exclusiva, y el modo de fallo es una excepción dura en el arranque: una elección de diseño deliberada para que un mal uso no pueda coexistir con una instalación funcional.
La convención de la bolsa de argumentos
Cada fire / collect / perform / filter tiene la misma forma: $name, $value (o default), $params. El array $params se desempaqueta posicionalmente en el callback registrado. Cada callback de la cadena recibe los mismos args.
- Mantenga las bolsas de args pequeñas y estables. Una vez que un hook se entrega, los args pasan a ser un contrato: añadir un arg posicional rompe cada plugin que ya hubiera vinculado el closure con una firma fija.
- Pase modelos, no bolsas de campos.
fire('customer_added', [$customer]) deja al listener decidir qué campos leer; fire('customer_added', [$customer->email, $customer->uid, ...]) bloquearía los args a los campos que existieran cuando se entregó el hook.
- Use hooks adicionales en lugar de sobrecargar los args. Si un slot necesita contexto más rico, registre una clave de hook separada en lugar de añadir un quinto arg posicional opcional.
El truco del collect por referencia
Una porción pequeña del core usa collect() con argumentos por referencia como hook de mutación, fuera del modelo canónico de cuatro patrones. El call-site filter_aws_ses_dns_records es el ejemplo canónico:
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
El host dispara el hook esperando que los plugins muten $dkims y $spf in situ. Hablando con rigor, esto es un mal uso de REGISTRY: una cadena FILTER de verdad habría sido la forma correcta. El comportamiento funciona porque los closures de PHP respetan los args por referencia, pero los autores de plugin no deberían escribir nuevos call-sites con este estilo. Recurra a FILTER (un solo valor transformado) o a REGISTRY (varias contribuciones inmutables) en su lugar.
Seis antipatrones a evitar
Los patrones de abajo parecen todos correctos a primera vista y se rompen de formas sutiles. Cada uno está fundamentado en una clase de bug ya vista en plugins de producción.
1. Registrar hooks en register() cuando necesitan boot()
El register() del host se ejecuta antes que el boot() de cualquier service provider. Los hooks registrados ahí se disparan antes de las dependencias conectadas por el register() de otros providers. Síntoma: Class not found en la primera petición, antes de que cualquier código de plugin ejecute su ruta principal. Solución: solo el hook add_translation_file pertenece a register(); todo lo demás va en boot().
2. Recurrir a BEHAVIOR cuando el objetivo es «compartido»
BEHAVIOR lanza una excepción en un segundo set. Si dos plugins necesitan legítimamente influir en el mismo punto, FILTER (componer) o REGISTRY (recoger) son la forma correcta. Solución: reescriba el contrato del hook — emita una cadena o una lista, no un callable exclusivo.
3. Poner setIfEmpty en un service provider
setIfEmpty solo surte efecto si nadie más ha registrado todavía. Colocarlo en register() o boot() significa que podría ejecutarse antes del set() de un plugin, dejándolo fuera. Solución: coloque setIfEmpty directamente encima de la llamada a perform coincidente, en el controlador o el modelo, para que el boot() de cada plugin ya haya terminado.
4. Mutar estado compartido dentro de un callback REGISTRY
collect llama a cada callback en el orden de registro. Un callback que escribe en una caché compartida o en la sesión como efecto secundario hace que el hook no sea determinista: ejecutarlo dos veces con un orden de plugins distinto da un estado de caché distinto. Solución: mantenga puros los callbacks de add. Si la contribución depende de efectos secundarios, registre un listener EVENT aparte.
5. Añadir args posicionales a un hook existente
Una vez que un hook está en producción, los plugins ya han vinculado closures con la aridad original. Añadir un quinto arg posicional rompe cada vinculación que lo omitiera. Solución: registre un nombre de hook nuevo (customer_added_v2) y acepte una ventana de transición, o pase el nuevo contexto a través del objeto modelo que ya está en la bolsa de args.
6. Usar collect() para una sola respuesta
REGISTRY devuelve un array: incluso cuando solo un plugin se registra, el host recibe [$result]. Tratar eso como la respuesta ($first = Hook::collect(...)[0]) elige silenciosamente al plugin que haya arrancado primero, sin semántica de conflictos. Solución: use BEHAVIOR si se espera exactamente una respuesta.
Cómo elegir un patrón
La decisión normalmente se reduce a cuatro preguntas. Respondiéndolas en orden, elige la forma correcta:
- ¿Deberían componer varios plugins? Si sí, REGISTRY (contribuciones independientes) o FILTER (transformación encadenada). Si no, BEHAVIOR (sobrescritura exclusiva).
- ¿Necesita el host un valor de retorno? Si no, EVENT (solo efectos secundarios). Si sí, REGISTRY / BEHAVIOR / FILTER.
- ¿Pasa el valor por varias manos? Si sí, FILTER (cadena). Si cada mano aporta de forma independiente, REGISTRY (recoger).
- ¿Es un bug el conflicto entre dos plugins? Si sí, BEHAVIOR (fallo ruidoso en el arranque). Si no, los patrones libres de conflicto.
Ante la duda, elija el patrón más laxo. REGISTRY más un EVENT «fuera de banda» para la limpieza es casi siempre más flexible que BEHAVIOR: la semántica de conflictos es lo que mata a BEHAVIOR en la práctica, y los patrones laxos permiten a los plugins componer sin coordinación.
A dónde ir después
Ya tiene los cuatro patrones en profundidad y la semántica de conflictos que hace predecible cada forma. Tres páginas convierten este conocimiento en las superficies del día a día que un plugin real tocará realmente: