Cuatro estados. Cuatro métodos de modelo. Un archivo maestro JSON.

Cada plugin de la aplicación host se mueve por cuatro estados discretos: register (archivos en disco + autoload + fila en BD), activate (migraciones + cambio de estado), disable (solo cambio de estado: rutas y hooks sobreviven), delete (rollback + eliminación de la fila de la BD + limpieza del archivo maestro). Cada estado está implementado por un único método en app/Model/Plugin.php; cada transición escribe tanto en la tabla plugins de la base de datos como en storage/app/plugins/index.json. Esta página recorre cada estado en orden con los pasos exactos del lado del host.

Los cuatro estados de un vistazo

Cada fila de plugin lleva una columna status con uno de dos valores: active o inactive. Dos estados más son implícitos: aún no registrado (sin fila en BD, sin entrada en el archivo maestro) y eliminado (directorio de archivos eliminado, fila eliminada, entrada del archivo maestro eliminada). Cuatro transiciones mueven el plugin entre ellos:

TransiciónMétodoEstado antesEstado despuésQué cambia en disco / BD
RegisterPlugin::register($name)(sin fila)inactiveFila en BD insertada; entrada del archivo maestro escrita; service provider arrancado en la petición actual
Activate$plugin->activate()inactiveactiveLas migraciones se ejecutan vía el hook de activate; estado en la BD cambiado; error del archivo maestro limpiado
Disable$plugin->disable()activeinactiveEstado en la BD cambiado; error del archivo maestro limpiado. Nada más.
Delete$plugin->deleteAndCleanup($keepData = false)cualquiera(sin fila)El hook de delete se dispara (normalmente migrate:rollback); carpeta del plugin eliminada; fila en la BD eliminada; entrada del archivo maestro eliminada

El modelo mental que conviene tener: register y delete cambian el mundo (archivos en disco, esquema de BD). Activate y disable solo voltean un flag: las rutas, vistas, hooks y el estado del service provider de register se quedan en su sitio. Las siguientes secciones cubren cada transición en orden.

Estado 1 — Register / install

Plugin::register($name) en app/Model/Plugin.php:559 es el punto de entrada. Se le llama automáticamente al final de php artisan plugin:init y en cada subida exitosa a través de la página de Plugins de administración. El método hace cinco cosas distintas en orden:

  1. Lee composer.json de storage/app/plugins/{vendor}/{name}/ y copia title, description y version al modelo. Lanza una excepción si el campo name de composer no coincide exactamente con el directorio.
  2. Inserta (o actualiza) la fila en la tabla plugins de la BD con status = inactive. La búsqueda es firstOrNew(['name' => $name]), así que volver a registrar un plugin existente actualiza en lugar de duplicar.
  3. Escribe el archivo maestro: storage/app/plugins/index.json recibe una entrada { "name": { "status": "inactive" } }. Este es el registro en el momento de arranque que el host lee en cada petición sin ir a la BD.
  4. Carga el service provider de inmediato: $plugin->load($withServiceProvider = true) registra el prefijo PSR-4 con un Composer\Autoload\ClassLoader nuevo y llama a App::register() sobre la clase del service provider del plugin. Cuando el método retorna, las rutas, vistas y hooks del plugin están conectados al proceso en ejecución.
  5. Materializa las traducciones y publica los assets: Language::dump() crea archivos de runtime por locale bajo storage/app/data/plugins/{vendor}/{name}/lang/ y luego artisan vendor:publish --force --tag=plugin copia los assets empaquetados a public/plugins/{vendor}/{name}/.

Después del register, el plugin está instalado y cargado. Todavía no está activo: eso solo significa que lo que el plugin haya conectado a su evento activate no se ha ejecutado. Las rutas, vistas y listeners de hook del plugin ya están activos.

Estado 2 — Activate

$plugin->activate() en Plugin.php:484 es lo que llama el botón «Activar» del admin. Cuatro pasos ordenados:

  1. Dispara el hook de activate: Hook::fire('activate_plugin_'.$this->name). Se ejecuta cada listener registrado contra este nombre, normalmente el propio listener del plugin Hook::on('activate_plugin_*', ...) que llama a artisan migrate contra la carpeta de migraciones del plugin. Otros plugins pueden registrar listeners adicionales sobre el mismo evento.
  2. Vuelve a validar composer.json: self::validateMetaData($config) verifica que las claves obligatorias del plugin (name, version, app_version) estén presentes y bien formadas. Las claves faltantes lanzan una excepción antes de que el cambio de estado se aplique.
  3. Pone el estado en la BD a active y guarda la fila.
  4. Actualiza el archivo maestro: { "status": "active", "error": null }: el reseteo del error limpia cualquier fallo previo de arranque para que los próximos barridos de autocarga traten al plugin como sano.

La activación es idempotente en la práctica. Volver a ejecutar activate() sobre un plugin ya activo vuelve a disparar el hook (así que los listeners que ejecutaron migrate lo ejecutarían de nuevo: la tabla de migraciones de Laravel deduplica los archivos ya ejecutados, así que la segunda invocación es un no-op), vuelve a validar y escribe el mismo estado. Sin una rama especial de «ya activo».

Estado 3 — Disable

$plugin->disable() en Plugin.php:136 es el más simple de los cuatro métodos. Hace solo esto:

  1. Pone el estado en la BD a inactive.
  2. Actualiza el archivo maestro con el nuevo estado y limpia cualquier campo error.

Ese es el método entero. No descarga nada.

Las rutas registradas durante el boot() del plugin se quedan registradas. Las vistas siguen siendo montables. Los listeners de hook siguen disparándose cuando el host dispara su hook. El service provider del plugin sigue cargado en el container de la aplicación y se cargará otra vez en la siguiente petición porque autoloadWithoutDbQuery() lee cada entrada del archivo maestro sin importar el estado. Disable es un cambio de estado, no una descarga: el propio Laravel no admite desregistrar un service provider después de su boot.

Por eso el plugin acelle/console es el patrón canónico de «las funcionalidades del plugin deberían desaparecer cuando se desactive»: las rutas siempre cargan, pero un middleware de ruta llamado console.active aborta con 404 cuando Plugin::getByName('acelle/console')->isActive() devuelve false. La comprobación ocurre en cada petición, contra el estado actual de la BD, así que desactivar el plugin hace que sus rutas devuelvan 404 a partir de la siguiente petición.

El patrón de desactivación visible en tres pasos. (1) Defina un middleware de ruta que compruebe Plugin::enabled('myvendor/myplugin') y aborte 404 cuando sea false. (2) Regístrelo como alias de middleware en el boot() de su service provider. (3) Aplíquelo a su grupo de rutas en routes.php. Cada plugin que entregue funcionalidades visibles para el usuario debería seguir este patrón: sin él, «desactivado» parece idéntico a «activado» desde la perspectiva del usuario.

Estado 4 — Delete

$plugin->deleteAndCleanup($keepData = false) en Plugin.php:670 es el desmontaje completo. Cuatro pasos ordenados:

  1. Dispara el hook de delete: Hook::fire('delete_plugin_'.$name, [$keepData]). El listener del esqueleto llama a artisan migrate:rollback contra la carpeta de migraciones del plugin. El flag $keepData se reenvía para que el listener pueda excluirse de revertir tablas que contienen datos de clientes: vea la página de base de datos y modelos para el patrón trabajado.
  2. Elimina el directorio del plugin: $this->deletePluginDirectory() elimina recursivamente storage/app/plugins/{vendor}/{name}/. Después de este paso, el código fuente PHP del plugin desaparece del disco.
  3. Elimina la fila de la BD. La tabla plugins ya no hace referencia a este plugin.
  4. Quita la entrada del archivo maestro: updatePluginMasterFile($name, null): el null es la señal convencional para descartar la entrada en lugar de fusionar campos nuevos.

Hasta que la siguiente petición arranque un proceso nuevo, las rutas, vistas y hooks del plugin siguen cargados en memoria: el container de Laravel en proceso no tiene el concepto de «desregistrar el service provider de este plugin». La siguiente petición lee el archivo maestro (ahora reducido), no carga el plugin, y el estado en memoria se descarta con el ciclo de vida de la petición anterior.

El archivo maestro en cada transición

storage/app/plugins/index.json es la única fuente de verdad en el momento de arranque. Cada transición de arriba escribe en él. Una forma útil de ver el ciclo de vida es observar cómo se ve la entrada de un plugin en cada paso:

// Before register: no entry.
{}

// After register:
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After activate:
{
  "acmecorp/loyalty": { "status": "active" }
}

// After a boot failure (sticky until cleared by activate):
{
  "acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}

// After disable (error cleared, status flipped):
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After delete: no entry.
{}

Tres métodos del lado del host son dueños del archivo: updatePluginMasterFile($name, $params) para escrituras de fusión (pase null como segundo arg para eliminar la entrada), resetPluginMasterFile() para reconstruir el archivo desde Plugin::all() cuando se desincroniza con la BD, y getErroredPluginNames() para leer cada entrada y devolver los nombres con error no vacío.

Recuperación de un estado roto

Tres modos de fallo aparecen en producción:

1. La fila del plugin en el archivo maestro está obsoleta o equivocada

Común después de ediciones manuales, despliegues parciales o restaurar una snapshot de la base de datos. Solución: ejecute php artisan tinker y llame a Plugin::resetPluginMasterFile(). El método itera Plugin::all() desde la BD y reescribe el archivo JSON desde cero, preservando el estado y limpiando cada campo error.

2. El campo error de un plugin está puesto y la página de Plugins de administración muestra la píldora roja

El error es persistente: se pone cuando autoloadWithoutDbQuery() envuelve una llamada a loadPluginByName() en un try/catch y la llamada lanza una excepción. El error permanece hasta que un activate() exitoso (que pone error => null) o un disable() (igual). Solución: resuelva el problema subyacente (autoload.psr-4 ausente, namespace que no coincide, clase de service provider ausente) y luego haga clic en Activar; el siguiente arranque tendrá éxito y el error se limpiará.

3. La carpeta del plugin falta pero la entrada del archivo maestro permanece

Ocurre después de un rm -rf manual. El arranque sigue intentando cargar el plugin vía la entrada del archivo maestro, lanza una excepción y registra el error. Solución: quite la entrada del archivo maestro directamente con Plugin::updatePluginMasterFile($name, null), o, si el plugin debería seguir existiendo, vuelva a subir el archivo del código fuente y ejecute Plugin::register($name) de nuevo para repoblar todo.

Los comandos de consola plugin:*

Un solo comando de artisan viene en el host: plugin:init. No hay comandos plugin:activate, plugin:disable ni plugin:delete: esas son acciones de la UI de administración. El acceso programático pasa directamente por los métodos del modelo:

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();   // → active, runs migration via activate hook
>>> $p->disable();    // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true);  // → preserve customer-facing tables

Esta es la misma superficie que la página de Plugins de administración usa internamente. Los scripts de CI, seeders y tests de integración recurren a estos métodos directamente. El análisis a fondo de Testing cubre el patrón a nivel de testsuite.

Transiciones de estado en un diagrama

          ┌─────────────────────┐
          │   not registered    │  (no row, no master-file entry)
          └──────────┬──────────┘
                     │ Plugin::register($name)
                     │  ├─ writes DB row (status=inactive)
                     │  ├─ writes master file
                     │  ├─ loads service provider in-process
                     │  └─ Language::dump() + vendor:publish
                     ▼
          ┌─────────────────────┐
   ┌────▶ │      inactive       │ ◀───┐
   │      └──────────┬──────────┘     │
   │                 │                │
   │       activate()│                │ disable()
   │                 │                │   ├─ status=inactive
   │                 │                │   └─ master file updated
   │                 ▼                │
   │      ┌─────────────────────┐     │
   │      │       active        │ ────┘
   │      └──────────┬──────────┘
   │                 │
   │     deleteAndCleanup($keepData)
   │                 │  ├─ fires delete hook (rollback unless $keepData)
   │                 │  ├─ removes plugin folder
   │                 │  ├─ deletes DB row
   │                 │  └─ removes master-file entry
   │                 ▼
   │      ┌─────────────────────┐
   └──────│   not registered    │
          └─────────────────────┘
          (cycle: register again to re-install)

Cinco antipatrones

1. Tratar el disable como si descargara el plugin

Las rutas siguen registrándose, los hooks siguen disparándose, las vistas siguen montándose. Solución: proteja las funcionalidades visibles para el usuario con un middleware Plugin::enabled(...) o una comprobación en línea, exactamente como acelle/console.

2. Editar manualmente el archivo maestro en producción

Es fácil corromper el JSON. Solución: llame a Plugin::updatePluginMasterFile() o Plugin::resetPluginMasterFile() a través de tinker: ambos validan.

3. rm -rf storage/app/plugins/{vendor}/{name} sin quitar la entrada del archivo maestro

El arranque sigue intentando cargar el plugin ausente y registra el error. Solución: empareje siempre una eliminación de carpeta con Plugin::updatePluginMasterFile($name, null), o use deleteAndCleanup() que hace ambas cosas.

4. Llamar a activate() desde dentro del boot() de un service provider

La fase de boot se ejecuta una vez por proceso; llamar a activate() ahí dispara el hook de activate en cada petición. La migración se ejecuta cada vez (idempotente, pero cara), y los listeners con efectos secundarios también se disparan. Solución: la activación es una acción de la UI de administración, nunca un efecto secundario del arranque.

5. Olvidar que register ocurre antes que activate

Algunos plugins intentan sembrar datos por defecto vía un listener del hook activate y referencian modelos Eloquent que dependen de las propias migraciones del plugin, pero las migraciones aún no se han ejecutado en el primer activate. Solución: el listener de migración se ejecuta durante activate, antes que cualquier otro listener de Hook::on('activate_plugin_*') que pudiera referenciar las tablas nuevas. Ordene sus registros para que la migración vaya primero (en el esquema es así, manténgalo así).

A dónde ir después

El ciclo de vida cubre el cuándo; Testing cubre el verificar. La página siguiente recorre el registro del testsuite en phpunit.xml, el patrón de la clase base PluginTestCase, las aserciones de hooks bajo test y el ciclo de CI activate-test-delete.