Qué es un plugin aquí
Un plugin es un paquete Laravel autónomo que vive en storage/app/plugins/{vendor}/{name}/ dentro de la instalación host de AcelleMail. Lleva su propio composer.json, su propio namespace PSR-4, su propio service provider, sus propias rutas, vistas, migraciones y traducciones. Está estructurado exactamente como una pequeña aplicación Laravel, salvo por una distinción decisiva.
La aplicación host no instala el plugin a través del autoloader raíz de Composer. No hay paso composer require, ni directorio vendor/{vendor}/{name}/, ni entrada en composer.lock. En su lugar, cada vez que la aplicación arranca, hace lo siguiente por su cuenta:
- Lee el
composer.json propio de cada plugin.
- Registra el namespace PSR-4 declarado ahí con una instancia nueva de
Composer\Autoload\ClassLoader.
- Llama a
App::register(...) sobre los service providers listados bajo extra.laravel.providers.
La decisión fue deliberada. Tratar los plugins como paquetes instalados por Composer habría convertido el composer.json de la aplicación host en un blanco móvil: cada instalación, desactivación o actualización mutaría el lockfile. El loader en tiempo de ejecución mantiene estable el grafo de dependencias del host: los plugins se entregan con sus propios metadatos, y el host puede escanearlos, ignorarlos o reordenarlos sin tocar vendor/.
Los cinco archivos que rigen todo el sistema
Casi todo el comportamiento del ciclo de vida del plugin está implementado en cinco archivos de la aplicación host. Leer el código de estos es la forma más rápida de confirmar cualquier cosa de esta documentación:
| Archivo | Responsabilidad |
app/Console/Commands/InitPlugin.php | El punto de entrada CLI para php artisan plugin:init. Un envoltorio fino sobre Plugin::init($name). |
app/Model/Plugin.php | Todo el ciclo de vida: scaffold, registrar, cargar, activar, desactivar, eliminar, además de la maquinaria del archivo maestro. |
app/Library/HookManager.php | Las primitivas de inyección que los plugins usan para extender el comportamiento del core: REGISTRY, EVENT, BEHAVIOR, FILTER. Unas 160 líneas, sin dependencias. |
app/Providers/AppServiceProvider.php | Autocarga de plugins y registro de traducciones en el arranque. El único call site que conecta los plugins con la aplicación en ejecución. |
app/Model/Language.php | Materializa los archivos de traducción de los plugins en storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. La indirección que permite a los administradores editar traducciones a través de la UI de Idiomas sin tocar los archivos fuente del plugin. |
En total, estos cinco archivos suman bastante menos de tres mil líneas de código del lado del host. El sistema de plugins es pequeño a propósito: cada restricción que tiene un plugin viene de uno de esos cinco archivos, y no hay ningún otro sitio donde mirar.
El flujo de arranque y carga
Cada petición, cada queue worker, cada tick del scheduler y cada comando de Artisan pasa por la misma secuencia de arranque. La parte relevante para los plugins se ve así:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
Dos detalles de implementación en esta secuencia tienen consecuencias enormes para los autores de plugins:
1. La detección en el arranque nunca consulta la base de datos
La lista de plugins a cargar viene de storage/app/plugins/index.json, no de la tabla de base de datos plugins. A los service providers no se les permite consultar la base de datos con seguridad: en el momento en que se ejecuta AppServiceProvider::boot(), la conexión podría no existir todavía (comandos CLI como artisan db:create) o el esquema podría no estar migrado (montaje de tests de CI). Guardar el registro de arranque en un archivo JSON esquiva todo ese problema.
La tabla de BD sigue existiendo. Almacena el mismo status que el archivo JSON, más metadatos para el usuario como title, description y version. La página de Plugins de administración lee de la BD; el loader de arranque lee del JSON. Ambos se mantienen sincronizados por Plugin::register(), activate() y disable(): cada cambio de estado escribe en los dos almacenes.
2. autoloadWithoutDbQuery() actualmente carga todos los plugins del índice, incluidos los inactivos
La implementación actual itera cada entrada de index.json y llama a loadPluginByName sobre ella, sin importar el status. La razón es pragmática: incluso un plugin inactivo necesita sus rutas registradas (para que las páginas de administración sigan funcionando cuando un admin haga clic en «desactivar» sin recargar inmediatamente), y necesita sus traducciones disponibles (para que los dump-clones no queden obsoletos).
La consecuencia es que «inactivo» en el sistema de plugins de AcelleMail no es lo mismo que «descargado». La siguiente sección hace que la distinción quede precisa.
El contrato del composer.json
El composer.json de un plugin no son solo metadatos: es el contrato en tiempo de ejecución del que depende el loader. Las claves que importan son:
| Clave | Propósito |
name | ID canónico del plugin. Debe coincidir exactamente con el directorio bajo storage/app/plugins/. Plugin::register() lanza una excepción si difieren. |
autoload.psr-4 | Mapea el prefijo de namespace del plugin a src/. Obligatorio: sin ello, loadPluginByName() lanza una excepción y el plugin no puede arrancar. |
extra.laravel.providers | Array de nombres de clase totalmente cualificados. El loader llama a App::register() sobre cada uno. Obligatorio si el plugin quiere registrar rutas, vistas, hooks o cualquier otra cosa. |
extra.setting-route | El controlador@método al que enlaza el botón «Settings» de la página de Plugins de administración. Opcional: los plugins sin configuración pueden omitirlo. |
title, description, version | Se muestra en el listado de Plugins de administración. title es obligatorio; los demás caen a valores por defecto. |
El mapeo de autoload se registra en tiempo de ejecución, no en la instalación. No hace falta ejecutar composer dump-autoload después de editar el mapa PSR-4 del plugin: el host instancia un ClassLoader nuevo en cada petición y vuelve a leer el archivo. Esta es también la razón por la que cambiar el namespace de un plugin no requiere más que un buscar-y-reemplazar más una petición al host.
El archivo maestro (storage/app/plugins/index.json)
El archivo maestro es un objeto JSON plano indexado por nombre de plugin. Cada entrada almacena como mínimo un status, más una cadena error opcional cuando el último intento de arranque falló. Un archivo típico se ve así:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
Tres métodos del lado del host son dueños de este archivo. Cada cambio de estado pasa por uno de ellos:
Plugin::updatePluginMasterFile($name, $params) — escribe-fusiona la entrada de un solo plugin. Pase null como segundo argumento para eliminar la entrada por completo (camino del delete).
Plugin::resetPluginMasterFile() — reconstruye el archivo desde cero iterando Plugin::all(). Se usa como recuperación cuando el JSON se corrompe o se desincroniza con la BD.
Plugin::getErroredPluginNames() — lee cada entrada y devuelve los nombres con error no vacío. El listado de Plugins de administración lo usa para empujar los plugins rotos al fondo y mostrar la píldora roja de error.
La clave error se establece cuando autoloadWithoutDbQuery() envuelve una llamada a loadPluginByName() en un try/catch y la llamada lanza una excepción. El mensaje de la excepción se registra para que la UI de administración tenga algo que mostrar sin volver a ejecutar el fallo. Reactivar un plugin limpio limpia el campo automáticamente.
El archivo maestro es la única fuente de verdad en el momento de arranque. Si alguna vez necesita recuperarse de un plugin atascado (la UI de administración está caída, la base de datos está offline), edite storage/app/plugins/index.json directamente. La siguiente petición lee el estado actualizado y se comporta en consecuencia. La fila de la BD son los metadatos a largo plazo; el archivo JSON es el registro en tiempo de ejecución.
Tiempo de register() frente a boot()
Laravel ejecuta primero el método register() de cada service provider, en el orden de registro, antes de llamar a ningún boot(). Esto es Laravel de manual, pero tiene consecuencias directas en el sistema de plugins.
Qué va en register()
- Constantes y bindings: necesitan existir antes de que se ejecute el
boot() del propio host.
- El hook
add_translation_file, y solo este hook. El AppServiceProvider::boot() del host llama a Hook::collect('add_translation_file') en su propia fase de boot. Para cuando se ejecuta el boot() de un plugin, ese bucle ya ha terminado. Si un plugin registra su entrada de traducción en boot(), nunca se recoge, y trans('myname::messages.intro') devuelve la clave literal.
Qué va en boot()
- Rutas y vistas:
$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
- Publicación de assets:
$this->publishes([...], 'plugin').
- Listeners de eventos del ciclo de vida:
Hook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
- La URL del icono:
Hook::set('icon_url_{vendor}/{name}', ...).
- Todos los demás hooks: REGISTRY
add, EVENT on, BEHAVIOR set, FILTER modify. Cualquier cosa que dependa de bindings del container, de la configuración o de otros plugins.
No llame a $this->loadTranslationsFrom(...) en el boot() de su plugin. El host ya ha conectado el namespace a través del hook add_translation_file, apuntándolo a los archivos de runtime volcados bajo storage/app/data/plugins/.... Un segundo loadTranslationsFrom desde el boot() de su plugin sobrescribe la indicación del host y vuelve a apuntar el namespace al archivo maestro bajo resources/lang/.... El síntoma visible es que las ediciones de los admins en la UI de Idiomas dejan de surtir efecto en runtime: los clones volcados se convierten en archivos zombis. Use solo el hook.
Por qué los plugins inactivos siguen afectando a la app
La llamada a autoloadWithoutDbQuery() en el arranque carga todos los plugins de index.json sin importar su estado. Así que un plugin «inactivo» sigue teniendo todo lo siguiente registrado con el host:
- Sus rutas, declaradas por
$this->loadRoutesFrom(...) en boot().
- Sus vistas, declaradas por
$this->loadViewsFrom(...).
- Sus alias de middleware, registrados por las APIs estándar de Laravel.
- Sus listeners de hook: cada
Hook::add, Hook::on, Hook::modify, Hook::set sigue disparándose.
- Sus fragmentos de UI: todo lo aportado vía
layout.head.assets, layout.body.before_close, admin.sidebar.groups o los hooks REGISTRY de slots de página sigue apareciendo.
Lo que la activación añade en realidad es lo que el autor del plugin conectó a activate_plugin_{vendor}/{name}. El listener del esqueleto ejecuta la migración. No hay un paso implícito de «registrar rutas cuando esté activo» o «quitar rutas cuando esté inactivo»: las rutas se registraron en el momento en que arrancó la aplicación.
Si una funcionalidad debe desaparecer de verdad cuando un admin desactive el plugin, el autor del plugin tiene que protegerla explícitamente. El patrón convencional vive en storage/app/plugins/acelle/console: las rutas siempre se cargan, pero un middleware de ruta llamado console.active aborta con 404 cuando Plugin::getByName('acelle/console')->isActive() devuelve false. Copie ese patrón cuando «desactivado» deba significar «inaccesible».
Lo mismo aplica a los hooks de UI. Si una burbuja de chatbox inyectada a través de layout.body.before_close debe ocultarse cuando el plugin esté inactivo, el cuerpo del closure debe comprobar Plugin::enabled('myvendor/myplugin') primero y devolver null cuando sea false. El host filtra los retornos falsy automáticamente antes de renderizar.
Ciclo de vida: register / activate / disable / delete
Cuatro estados, cuatro métodos del lado del host. Cada uno es preciso sobre qué hace y qué no cambia.
Register / install
Plugin::register($name) es el punto de entrada: se le llama automáticamente al final de plugin:init y en cada subida exitosa a través de la UI de administración. Los cinco pasos son:
- Lee
composer.json y copia title / description / version al modelo.
- Inserta o actualiza la fila en
plugins con status = inactive.
- Escribe
storage/app/plugins/index.json con { "name": { "status": "inactive" } }.
- Llama a
Plugin::load($withServiceProvider = true): registra el prefijo PSR-4 y arranca el service provider inmediatamente, de modo que cualquier ruta / vista / hook queda activo en el proceso actual.
- Llama a
Language::dump() para materializar los archivos de traducción y luego ejecuta vendor:publish --tag=plugin --force para copiar los assets empaquetados a public/plugins/....
Después del register el plugin está instalado y cargado. Lo único que falta es lo que el plugin haya elegido conectar a su evento activate, normalmente la ejecución de una migración.
Activate
Se llama a $plugin->activate() desde el botón «Activate» de la UI de administración (y desde tests / seeders que llaman al modelo directamente). Hace cuatro cosas, en orden:
- Dispara
Hook::fire('activate_plugin_'.$name). El listener del esqueleto ejecuta artisan migrate contra storage/app/plugins/{vendor}/{name}/database/migrations. Otros plugins pueden registrar listeners adicionales (comportamiento REGISTRY, cada listener se dispara).
- Vuelve a validar el
composer.json del plugin contra la lista de claves obligatorias del host (name, version, app_version).
- Pone el
status de la BD a active.
- Actualiza el archivo maestro:
{ "status": "active", "error": null }, limpiando cualquier error de arranque previo.
Disable
$plugin->disable() solo:
- Pone el
status de la BD a inactive.
- Actualiza el archivo maestro con el nuevo estado y limpia cualquier
error registrado.
No descarga rutas, vistas, service providers, listeners de hook ni nada que se registrara en el arranque. El host no tiene el concepto de «desregistrar un service provider»: el propio Laravel no lo admite. Disable es un cambio de estado, no una descarga.
Delete
$plugin->deleteAndCleanup($keepData = false) recorre el desmontaje completo:
- Dispara
Hook::fire('delete_plugin_'.$name, [$keepData]). El listener del esqueleto ejecuta migrate:rollback; $keepData = true puede saltarse eso para plugins que poseen datos que el admin quiere conservar.
- Borra recursivamente el directorio del plugin bajo
storage/app/plugins/....
- Borra la fila de la tabla
plugins de la BD.
- Quita la entrada del archivo maestro.
Hasta que la siguiente petición arranque un proceso nuevo, el service provider del plugin sigue cargado en memoria. La siguiente petición lee el archivo maestro (ahora reducido), no carga el plugin, y el estado en proceso se descarta con el ciclo de vida de la petición.
Dos capas de inyección
Un plugin influye en la aplicación host a través de dos capas paralelas. Distinguir entre ellas es lo que hace que el resto de la documentación se mapee limpiamente al código.
Capa 1 — Registro Laravel
A través del service provider, un plugin usa las APIs estándar del container de Laravel para extender la aplicación:
$this->loadRoutesFrom(__DIR__ . '/../routes.php'): añade la superficie HTTP del plugin.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname'): expone vistas Blade bajo el namespace myname::view.
$this->publishes([...], 'plugin'): copia los assets empaquetados a public/plugins/{vendor}/{name}/ del host al instalar.
- Alias de middleware, bindings del container, comandos de consola, tareas programadas, listeners de cola: todo lo que el propio Laravel admite.
Capa 2 — Inyección basada en hooks
El host llama a las primitivas de App\Library\HookManager en puntos de extensión cuidadosamente elegidos. Los plugins registran listeners contra esos puntos para participar. Hay exactamente cuatro patrones: REGISTRY, EVENT, BEHAVIOR, FILTER. El siguiente análisis a fondo, El sistema de Hooks, cubre cada uno por completo.
Dos cosas a saber ahora: (1) cada hook que dispara el host es un contrato estable: una vez publicados, el nombre y la firma no cambian entre versiones. (2) BEHAVIOR es exclusivo: si dos plugins intentan hacer Hook::set sobre el mismo nombre, la segunda llamada lanza una excepción de inmediato. No hay sobrescritura silenciosa; los conflictos afloran en el arranque, no en producción.
El código entrega tres hooks REGISTRY a nivel de layout que casi todos los plugins que extienden la UI usan:
| Clave del hook | Dónde se dispara | Para qué se usa |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php, antes de @yield('head') | CSS / JS que debe cargarse antes del contenido de la página (estilos del chatbox, scripts del popover de Sparkle) |
layout.body.before_close | Los mismos layouts, justo antes de </body> | Widgets flotantes: burbuja del chatbox, modales, popover 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 |
Los tres siguen el mismo idiom: cada callback devuelve HTML renderizado o null; el host itera con array_filter y emite cada fragmento con {!! !!}. Devolver null es la forma convencional de controlar una contribución mediante un feature flag o el estado del plugin sin lanzar excepciones.
Flujo de traducciones en tiempo de ejecución
Las traducciones del plugin no se sirven directamente desde la carpeta resources/lang/ de las fuentes del plugin. El flujo es indirecto, y esa indirección es lo que permite a los administradores editar traducciones a través de la UI de Idiomas del host sin comprometerse con los archivos fuente del plugin. La secuencia verificada:
- El
register() del plugin contribuye una entrada Hook::add('add_translation_file', ...) que apunta a storage/app/data/plugins/{vendor}/{name}/lang/.
- El
AppServiceProvider::boot() del host recoge todas esas entradas y llama a $this->loadTranslationsFrom() contra cada una.
- En cada
Plugin::register(), el host llama a Language::dump().
Language::dump() lee el archivo maestro del plugin en resources/lang/en/messages.php y lo copia a storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php para cada locale admitido.
- La UI de administración de Idiomas edita los archivos de runtime volcados. El archivo maestro fuente del plugin permanece intacto.
Dos rutas que conviene recordar:
- Archivo maestro (esto lo edita en el código fuente):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- Archivos de runtime (autogenerados, lo que la app lee realmente):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
Cuando edite el archivo maestro, ejecute php artisan translation:upgrade para resincronizar el maestro a todos los archivos de runtime de los locales (preservando las traducciones que los admins hayan editado vía la UI de Idiomas). La mecánica completa (maestro vs. runtime, semántica del upgrade, fallback por locale) tiene un análisis a fondo dedicado en Traducciones.
Qué implica esto para los autores de plugins
Cinco reglas surgen de la arquitectura anterior. Interiorizarlas convierte la mayor parte de la complejidad de superficie del resto de las docs en una comprobación contra esta lista.
- Trate
boot() como la fase de registro. Rutas, vistas, hooks, listeners del ciclo de vida: casi todo va aquí. Lo único que va en register() es el hook add_translation_file (porque el host lo recoge antes de que se ejecute el boot() de cualquier plugin).
- Inactivo no significa descargado. Cualquier cosa que registre en el arranque está activa sin importar el estado
active / inactive. Si una funcionalidad debe desaparecer de verdad cuando se desactiva, protéjala explícitamente con un middleware de ruta o una comprobación Plugin::enabled(...) dentro del closure del hook.
- Edite las traducciones a través del archivo maestro, nunca directamente con
loadTranslationsFrom(). Los clones volcados bajo storage/app/data/plugins/... son lo que el runtime lee. Apuntar usted mismo su namespace al directorio maestro sobrescribe la indicación del host y rompe la UI de Idiomas.
- Mantenga
composer.json fino y estable. El loader en tiempo de ejecución lo lee en cada petición. autoload.psr-4, extra.laravel.providers, name y title son las claves que el host realmente usa. Añadir claves extra no rompe nada, pero no hace nada tampoco.
- Los cuatro patrones de hook son el único contrato. Cuando se sorprenda queriendo «importar» una clase del core para extenderla, pare. El contrato de plugin va en una sola dirección: el core declara hooks, los plugins reaccionan. Si el punto de extensión que necesita no existe todavía como hook, lo correcto es abrir un issue contra el host, no hacer
use Acelle\Model\Customer desde el controlador de su plugin.
A dónde ir después
Tiene la arquitectura. Dos páginas convierten este modelo mental en las APIs del día a día a las que recurrirá:
- El sistema de Hooks: los cuatro patrones en profundidad, con call-sites reales extraídos del core con grep. La semántica de conflictos, cuándo usar qué patrón y los antipatrones que parecen correctos pero se rompen en producción.
- Inyección de UI: los hooks a nivel de layout de arriba, más el contrato
page.{controller}.{action}.{slot} que permite a un plugin inyectar una tarjeta en una página existente sin bifurcar ni una sola Blade.
Cuando esté listo para entregar un plugin de feature real, los ejemplos trabajados son Drivers de envío (Postal MTA de principio a fin) y Pasarelas de pago (Paddle como pasarela regional). Para un ejercicio completo de comprensión lectora, la muestra de Aurius recorre el plugin complejo canónico: ocho modelos, catorce migraciones, dieciocho locales y todas las superficies de hook usadas en producción.