Requisitos previos
Un plugin en este código es un paquete Laravel pequeño. Antes de generar el scaffold de uno, asegúrese de que la instalación host de AcelleMail que va a extender está ya en funcionamiento y de que dispone de un toolchain de PHP funcionando en su máquina local. Los comandos CLI de abajo asumen que está en la raíz de la aplicación (el directorio que contiene el archivo artisan).
Aplicación host
- AcelleMail v4.x instalado y sirviendo peticiones. El loader de plugins es parte de
App\Providers\AppServiceProvider: las builds 3.x antiguas no tienen Plugin::autoloadWithoutDbQuery().
- Un queue worker, el scheduler o una petición web capaces de llegar a la raíz de la aplicación: el loader se ejecuta en el arranque, no bajo demanda.
- Acceso de escritura a
storage/app/plugins/. El comando Artisan escribe el scaffold aquí, no en vendor/.
Conocimientos de PHP que conviene repasar
El sistema de plugins se apoya mucho en un puñado de fundamentos de PHP y Laravel. Si alguno de estos le suena oxidado, pause y repase las docs correspondientes antes de generar el scaffold: depurar un plugin que tiene el namespace incorrecto declarado en su composer.json es mucho más difícil que acertarlo a la primera.
- Autoloading PSR-4. El
composer.json del plugin mapea un prefijo de namespace al directorio src/. AcelleMail registra ese mapeo con un Composer\Autoload\ClassLoader nuevo en el arranque, así que la declaración de namespace en cada archivo PHP debe coincidir con el mapeo del composer.json exactamente, capitalización incluida.
- Closures y la palabra clave
use. Casi todos los listeners de hook son closures. Cuando el closure necesita una variable externa, hay que capturarla explícitamente. Olvidar esto es la fuente más común de errores de undefined variable en el código de plugins.
register() vs. boot() en un service provider. Laravel ejecuta primero el register() de cada provider y luego el boot() de cada provider. Los hooks listados en register() pueden ejecutarse antes de que sus dependencias estén listas; los hooks listados en boot() se ejecutan demasiado tarde para el colector de traducciones. Ambos son escopetas de pie reales: vea Siete errores del primer día.
- Eloquent, Blade, rutas, facades. Las migraciones del plugin usan el builder estándar
Schema, las vistas del plugin son archivos Blade normales, las rutas del plugin usan Route::group(...). Nada en un plugin es a medida: los archivos generados son Laravel puro.
No hace falta publicar el plugin en Packagist, ni ejecutar composer install dentro de la carpeta del plugin, ni registrar nada en el composer.json raíz del host. El loader en tiempo de ejecución gestiona cada paso.
Reglas de nombres — léalas una vez y ahórrese una hora
Cada plugin tiene una identidad de la forma {vendor}/{name}, por ejemplo Aurius, aix/sample, athena/evs. Esta identidad es la clave canónica en la tabla plugins de la base de datos, en el directorio storage/app/plugins/, en el archivo maestro storage/app/plugins/index.json y en los nombres de los hooks del ciclo de vida (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).
El validador en App\Model\Plugin::init() aplica un conjunto de reglas pequeño y conservador (regex canónico: ^[a-z0-9]+\/[a-z0-9]+$ con min:2 max:32 por lado):
- Solo letras minúsculas y dígitos. Sin guiones bajos, sin guiones, sin mayúsculas. La guía anterior que permitía guiones bajos ha quedado obsoleta: si ve
my_plugin en un README antiguo, eso ya no es válido.
- De dos a treinta y dos caracteres por lado.
a/sample falla (vendor demasiado corto); team/x falla (name demasiado corto).
- Exactamente una barra. Vendor y name. Sin anidamiento.
La regla de intersección conservadora viene de una limpieza de 2026-04 que alineó Plugin::init() con Plugin::getStoragePathByName(). Ambos validadores ahora están de acuerdo con el mismo regex: ya no hay forma de que un nombre genere un scaffold limpio y luego no cargue.
Elija el segmento del vendor con cuidado. El vendor es parte de cada namespace, de cada prefijo de URL en el routes.php de su plugin y de cada clave de traducción que el plugin emita. Renombrarlo más tarde significa un buscar-y-reemplazar por todos los archivos. acmecorp/loyalty es inequívoco; x/loyalty es inválido (vendor demasiado corto); acmecorp/loyaltypoints está bien.
El comando de scaffold
Desde la raíz de la aplicación, ejecute:
php artisan plugin:init {vendor}/{name}
Como ejemplo trabajado usaremos acmecorp/loyalty: el resto de esta página asume ese nombre. Sustitúyalo por el suyo propio cuando ejecute el comando.
$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder
El mensaje de éxito lo imprime App\Console\Commands\InitPlugin, que es un envoltorio fino sobre el método a nivel de modelo App\Model\Plugin::init($name). Ese método hace todo lo que describe el resto de esta página: validación, copia del scaffold, render con Twig, renombrado de archivos y luego una llamada encadenada a Plugin::register($name) que inserta la fila en la base de datos y arranca el service provider.
Para cuando vuelve el prompt, el plugin ya está cargado en la aplicación en ejecución como un paquete inactivo. Las rutas declaradas en su routes.php son alcanzables, las vistas son renderizables y cualquier hook que el service provider haya registrado está activo. Lo único que añadirá la activación es lo que el autor del plugin haya conectado al evento activate_plugin_{vendor}/{name}, normalmente la ejecución de una migración.
Qué se generó
El comando Artisan escribe un pequeño conjunto de archivos de arranque en storage/app/plugins/{vendor}/{name}/, renderiza los placeholders de Twig dentro de ellos y renombra la migración placeholder. La lista exacta de archivos está hardcodeada en Plugin::init(): ocho archivos con contenido renderizado más un par de assets estáticos. Ninguno de estos archivos es especial; son Laravel puro y es libre de borrarlos, renombrarlos o extenderlos.
El árbol de directorios en disco después de que el comando termina:
storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│ └── migrations/
│ └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│ ├── lang/
│ │ └── en/
│ │ └── messages.php
│ └── views/
│ └── index.blade.php
└── src/
├── Controllers/
│ └── DashboardController.php
├── Models/
│ └── Setting.php
└── ServiceProvider.php
Los ocho archivos de un vistazo
| Archivo | Para qué sirve |
composer.json | Contrato en tiempo de ejecución: name, autoload.psr-4 y extra.laravel.providers son obligatorios. Sin ellos el loader no puede registrar el namespace ni arrancar el provider. |
src/ServiceProvider.php | El único punto de entrada que ve Laravel. Registra traducciones en register(), y luego rutas, vistas, hooks del ciclo de vida y la URL del icono en boot(). |
src/Controllers/DashboardController.php | Una muestra desechable. Devuelve la vista index.blade.php incluida. Reemplácelo libremente. |
src/Models/Setting.php | Un modelo Eloquent vinculado a la primera migración del plugin. El nombre de tabla está con namespace como {vendor}_{name}_settings, así que los plugins no pueden colisionar en la misma BD. |
routes.php | Se carga desde el service provider. Declara tanto la ruta que sirve el icono (usada por la página de Plugins de administración) como una ruta de dashboard de muestra plugins/{vendor}/{name}. |
resources/views/index.blade.php | La vista de Hello World renderizada por DashboardController. Reemplácela por su UI real. |
resources/lang/en/messages.php | El archivo de traducción maestro. Language::dump() lo copia en storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ en tiempo de ejecución: los archivos volcados son los que la aplicación lee realmente. |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | La primera migración. Se ejecuta solo cuando el plugin se activa y se revierte cuando se elimina. El nombre del archivo es el único cuyos placeholders Twig no renderiza por sí solo: Plugin::init() lo renombra mediante una pasada aparte de str_replace. |
Un plugin de envío real crece más allá de esta superficie mínima. La referencia canónica dentro del código es storage/app/plugins/Aurius/: ocho modelos Eloquent, catorce migraciones, dieciocho locales, más de sesenta vistas, un grupo de barra lateral de administración, una burbuja de UI de chatbox y sus propios jobs ligados a cola. El esqueleto de Hello World es intencionadamente mínimo para que pueda reemplazar piezas una a una sin aprender cada subsistema de golpe. Los controladores extra van bajo src/Controllers/, los modelos extra bajo src/Models/, los servicios extra bajo src/Services/, las migraciones adicionales bajo database/migrations/.
Qué hizo Plugin::register() entre bastidores
La línea de salida dice creado y cargado, y es preciso. Entre copiar los archivos e imprimir el mensaje de éxito, Plugin::init() llama a Plugin::register($name), que ejecuta cinco pasos distintos:
- Lee el
composer.json del plugin. El campo name debe coincidir exactamente con el directorio (acmecorp/loyalty): una discrepancia lanza una excepción composer name in composer.json is expected to be ….
- Crea o actualiza la fila en la tabla
plugins de la base de datos. title, description y version se sacan de los metadatos de composer. El estado se fija a inactive.
- Escribe el archivo maestro.
storage/app/plugins/index.json es el registro en el momento de arranque: AppServiceProvider::boot() lee este archivo para decidir qué plugins autocargar, en cada petición, sin tocar la base de datos. La activación y la desactivación posteriores mutan el mismo archivo.
- Carga el service provider de inmediato. El
boot() del plugin se ejecuta en el proceso actual, así que cualquier ruta / vista / hook que registre está activa antes de la siguiente petición.
- Materializa los archivos de traducción.
Language::dump() lee cada entrada del hook add_translation_file, copia los archivos maestros a storage/app/data/plugins/... y termina ejecutando vendor:publish --tag=plugin --force para que los assets empaquetados aterricen bajo public/plugins/....
El modelo mental que conviene recordar: «instalado» ya significa «cargado». La activación es puramente un cambio de estado más lo que el autor del plugin haya conectado para dispararse en el evento de activación. No hay un paso separado de registrar las rutas que dispare la activación: las rutas se registran en el momento en que termina plugin:init.
Los plugins inactivos siguen estando cargados. La implementación actual de Plugin::autoloadWithoutDbQuery() carga todos los plugins listados en index.json, sin importar el estado. Si una funcionalidad debe desaparecer de verdad cuando el admin desactive el plugin, el autor del plugin tiene que protegerla explícitamente: un middleware de ruta que comprueba Plugin::getByName($name)->isActive() y aborta con 404 es el patrón convencional. El plugin de la consola de administración de la propia plataforma core es el ejemplo canónico.
Active el plugin
Con el plugin con el scaffold generado e inactivo, el siguiente paso es marcarlo como activo para que su listener de activate_plugin_{vendor}/{name} ejecute la migración. Dos caminos:
Desde la UI de administración
Inicie sesión como admin, abra /rui/admin/plugins, busque la entrada Loyalty y haga clic en Activar. La página renderiza el icono servido por su routes.php (el placeholder entrega un icon.svg en la raíz del plugin; reemplácelo por el suyo para marcar la entrada).
Por código (tests o seeders)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
Cualquiera de los dos caminos dispara Hook::fire('activate_plugin_acmecorp/loyalty'). El service provider del esqueleto registró un listener Hook::on(...) para ese evento en boot(): el listener llama a Artisan::call('migrate', ['--path' => ..., '--force' => true]), que crea la tabla acmecorp_loyalty_settings.
Visite /plugins/acmecorp/loyalty en un navegador y se renderiza la página de Hello World incluida. El blockquote @{{ trans('loyalty::messages.intro') }} tira del archivo de traducción volcado bajo storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.
Sus primeras ediciones
El esqueleto es intencionadamente mínimo para que pueda reemplazar piezas una a una sin aprender cada subsistema de golpe. Un orden razonable:
- Actualice
composer.json. Ponga un title, description y version reales. La página de Plugins de administración los renderiza.
- Añada una migración real. Coloque un archivo nuevo bajo
database/migrations/ con un timestamp mayor que el existente. Se ejecutará en la siguiente activación (o tras un ciclo de desactivar-y-reactivar).
- Añada un modelo real. El esqueleto entrega
Setting como placeholder. Añada el suyo bajo src/Models/; póngale como namespace {Vendor_class}\{Name_class}\Models\YourModel. Los nombres de clase se derivan automáticamente del vendor/name en minúsculas: acmecorp se convierte en Acmecorp y loyalty en Loyalty.
- Reemplace
DashboardController. Añada los controladores que su funcionalidad necesite realmente. Manténgalos finos: empuje la lógica de negocio a clases bajo src/Services/.
- Reemplace las vistas. El
index.blade.php incluido usa Bootstrap 5 desde un CDN. La mayoría de los autores de plugin lo quitan y extienden en su lugar el layout de la aplicación host.
- Añada hooks en
ServiceProvider::boot(). Vea el análisis a fondo del sistema de Hooks para los cuatro patrones. El esqueleto ya demuestra EVENT (Hook::on) y BEHAVIOR (Hook::set): REGISTRY y FILTER son los dos siguientes que aprender.
Siete errores del primer día y cómo arreglarlos
Casi cada reporte de los autores de plugin nuevos cae en una de estas siete categorías. Cada uno está fundamentado en código que viene en App\Model\Plugin o App\Providers\AppServiceProvider, así que los síntomas son predecibles.
1. El nombre viola al validador
plugin:init lanza una excepción con Plugin name must be in the "author/name" format o Author name "..." is invalid. Only lowercase letters and digits are allowed. Causa: el regex ^[a-z0-9]+\/[a-z0-9]+$ con min:2 max:32 por lado rechaza guiones bajos, guiones, mayúsculas o lados más cortos de dos caracteres.
Solución: use solo letras minúsculas y dígitos; por ejemplo acmecorp/loyalty, no acme_corp/loyalty-points.
2. El name del composer.json no coincide con la carpeta
Después del scaffold, Plugin::register() valida que el name en el composer.json renderizado coincida con la carpeta bajo storage/app/plugins/. Editar el JSON a otro vendor o name sin renombrar el directorio lanza Plugin name in composer.json is expected to be '{folder}', found '{json}'.
Solución: renombre el directorio y el JSON en bloque, o vuelva a ejecutar plugin:init con el nuevo nombre.
3. autoload.psr-4 ausente o mal formado
loadPluginByName() lanza Cannot boot plugin '{name}'. No 'autoload' found in composer.json (o la variante coincidente 'autoload.psr4') cuando el bloque autoload está eliminado o mal escrito. El runtime necesita ese mapa para registrar el namespace; sin él nada de src/ se puede instanciar.
Solución: mantenga la entrada autoload.psr-4 del scaffold. El prefijo de namespace que declara (Acmecorp\Loyalty\\) debe coincidir con la declaración de namespace al principio de cada archivo PHP bajo src/.
4. La declaración de namespace no coincide con el composer.json
El autoloader de PHP resuelve Acmecorp\Loyalty\Controllers\DashboardController a src/Controllers/DashboardController.php quitando el prefijo Acmecorp\Loyalty\\ declarado en composer.json. Si el archivo declara namespace AcmeCorp\Loyalty\Controllers (C mayúscula en AcmeCorp), el autoloader no lo encuentra. Síntomas: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found en la primera petición.
Solución: la declaración de namespace en cada archivo PHP bajo src/ debe usar la capitalización exacta derivada del vendor/name en minúsculas. Para acmecorp/loyalty, eso es Acmecorp\Loyalty. Plugin::makeClassNameFromString() aplica solo ucfirst: no hay capitalización inteligente.
5. Hook de traducción registrado en boot() en lugar de register()
AppServiceProvider::boot() 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: añadir la entrada de traducción ahí significa que nunca se recoge, y trans('loyalty::messages.intro') devuelve la clave literal.
Solución: registre las traducciones en register(), exactamente como hace el esqueleto. Los hooks del ciclo de vida para activate_plugin_* y delete_plugin_* siguen perteneciendo a boot().
6. Llamar a $this->loadTranslationsFrom(...) en boot()
Un instinto común es llamar al loadTranslationsFrom() de Laravel directamente además del hook. Como el boot() del plugin se ejecuta después del AppServiceProvider::boot, la segunda llamada sobrescribe la indicación de namespace que apuntaba a los archivos de runtime volcados (storage/app/data/plugins/...) y la vuelve a apuntar al archivo maestro (storage/app/plugins/.../resources/lang/...). El síntoma visible es que las ediciones del admin en la UI de Idiomas dejan de surtir efecto en runtime: los clones volcados se convierten en archivos zombis.
Solución: use solo el hook add_translation_file. No llame además a loadTranslationsFrom().
7. Hooks registrados en register() que dependen de otros plugins o del kernel
register() se ejecuta antes de que los register() de los demás providers hayan terminado y mucho antes de cualquier boot(). El código que necesita la base de datos, los servicios de otro plugin o cualquier singleton conectado en el register() de otro provider puede fallar con Class not found o Target class does not exist. El único hook que pertenece a register() es add_translation_file (porque tiene que ejecutarse antes del bucle de collect de AppServiceProvider::boot).
Solución: ponga el resto de los hooks en boot(). Si necesita absolutamente que algo se ejecute pronto, protéjalo primero con app()->runningInConsole() o isInitiated().
Checklist paso a paso
La secuencia completa para entregar un plugin funcional, de principio a fin:
php artisan plugin:init {vendor}/{name}: scaffold.
- Edite
composer.json: ponga title, description y version reales.
- Escriba sus migraciones bajo
database/migrations/.
- Añada modelos bajo
src/Models/.
- Añada controladores bajo
src/Controllers/.
- Añada vistas bajo
resources/views/.
- Declare rutas en
routes.php.
- Conéctelo todo en
ServiceProvider::boot(): vistas, rutas, hooks, publicación de assets.
- Inicie sesión en administración → Plugins → Activar. La migración se ejecuta automáticamente.
Cuando algo sale mal, dos puntos de entrada de depuración cubren casi todos los casos. storage/logs/laravel.log captura cualquier excepción lanzada durante el arranque, incluidas las levantadas dentro de loadPluginByName() al registrar el autoload. El campo error en cada fila de storage/app/plugins/index.json muestra el último fallo de arranque para ese plugin y es lo que la página de Plugins de administración usa para mostrar la píldora roja de error: limpiar el archivo reactivando el plugin (o eliminándolo y reinstalándolo) resetea el estado de error.
A dónde ir después
Ya tiene el scaffold, el ciclo de vida y los siete errores que controlan la mayor parte de la depuración del primer día. Las dos páginas siguientes le dan el modelo mental que asume el resto de la documentación:
- Arquitectura de plugins: el flujo de carga en el arranque, por qué los plugins inactivos siguen siendo autocargados, el mecanismo del archivo maestro y la diferencia entre
register() y boot() a nivel de runtime.
- El sistema de Hooks: los cuatro patrones (REGISTRY, EVENT, BEHAVIOR, FILTER), cuándo recurrir a cada uno y la semántica de conflictos que hace que BEHAVIOR lance una excepción en una colisión en lugar de sobrescribir silenciosamente.
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 el trabajo de UI, Inyección de UI cubre los hooks de layout / barra lateral / slot de página que permiten a un plugin montar una burbuja de chatbox o un panel de configuración sin bifurcar ni una sola Blade.