Tablas aisladas por plugin. Nombres prefijados por vendor. Migración automática al activar.

Los datos de un plugin viven en sus propias tablas, en la misma base de datos que usa la aplicación host, pero con nombres prefijados por la identidad {vendor}_{name} del plugin para que dos plugins no puedan colisionar nunca. Las migraciones viven dentro de la carpeta del plugin, se ejecutan cuando el admin hace clic en Activar, y se revierten cuando el admin hace clic en Eliminar. Hay una sola palanca canónica —el flag $keepData— para los plugins que son dueños de datos de usuario que el admin quiere preservar a través de reinstalaciones.

Por qué tablas aisladas por plugin

Un plugin que quiera estado persistente podría usar las tablas users, customers o plugins del host: ninguna de ellas sobreviviría a una actualización o a un renombrado. En su lugar, el sistema de plugins reserva una porción de la misma base de datos para tablas propiedad del plugin, aisladas por nombre. Tres propiedades caen de esa decisión:

  • Dos plugins en la misma instalación nunca colisionan. Las tablas de cada plugin se prefijan con la identidad {vendor}_{name} del plugin, que el validador ya restringe a letras minúsculas y dígitos. La tabla de settings de acmecorp/loyalty es acmecorp_loyalty_settings; la de otherteam/loyalty es otherteam_loyalty_settings. Mismo name, prefijo distinto.
  • La activación es lo único que crea tablas. El service provider del esqueleto escucha el evento por plugin activate_plugin_{vendor}/{name} y ejecuta artisan migrate contra la carpeta de migraciones propia del plugin. Hasta que un admin lo activa, el namespace del plugin se autocarga pero sus tablas no existen.
  • La eliminación puede ser limpia. El service provider del esqueleto también escucha delete_plugin_{vendor}/{name} y ejecuta migrate:rollback. Los plugins que son dueños de datos que el admin quiere conservar a través de reinstalaciones pueden excluirse vía el flag $keepData: vea más abajo.

Dónde viven las migraciones

Las migraciones del plugin viven en storage/app/plugins/{vendor}/{name}/database/migrations/. No van a la carpeta raíz database/migrations/ de la aplicación host: están completamente separadas. El php artisan migrate del host nunca las mira, por eso la activación tiene que hacer el trabajo explícitamente a través del hook del ciclo de vida.

El esqueleto genera una migración con el nombre de la tabla de settings del plugin, con un prefijo de timestamp 2000_01_01_000000_:

storage/app/plugins/acmecorp/loyalty/
└── database/
    └── migrations/
        └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php

El prefijo deliberado 2000_01_01 ordena la migración del scaffold primero. Las migraciones reales que añada después llevan timestamps con la fecha actual y se ejecutan en orden cronológico detrás de ella: aplican las reglas normales de orden de migraciones de Laravel dentro de la carpeta del plugin, aislada del orden del host.

Activate las ejecuta; delete las revierte

El src/ServiceProvider.php del esqueleto contiene dos listeners del ciclo de vida que conectan el runner de migraciones a los eventos de activate / delete. Ambos pertenecen a boot():

// Run plugin migrations when the plugin is activated.
Hook::on('activate_plugin_acmecorp/loyalty', function () {
    \Artisan::call('migrate', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);
});

// Roll back plugin migrations when the plugin is deleted.
Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
    if ($keepData) {
        return;
    }
    \Artisan::call('migrate:rollback', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);
});

La opción --path le dice a artisan migrate que opere solo sobre esta carpeta: deja intactas las migraciones del host y las de cualquier otro plugin. --force esquiva el prompt de confirmación en producción que artisan migrate normalmente requiere cuando APP_ENV=production; el propio evento del ciclo de vida es la confirmación del usuario.

La activación es idempotente: volver a ejecutar el bloque de migración sobre un plugin ya activado es seguro. artisan migrate lee la tabla de seguimiento migrations de Laravel y omite los archivos que ya ha ejecutado. Así que un admin que haga clic en Activar dos veces (o que tropiece con el endpoint REST de activate por accidente) obtiene el mismo estado final.

Nombres de tabla prefijados por vendor

Dos convenciones del código del host previenen colisiones juntas:

  1. El propio nombre del plugin está restringido a ^[a-z0-9]+\/[a-z0-9]+$ con 2-32 caracteres por lado. Así que {vendor}_{name} como prefijo nunca contiene una barra, un guion ni un guion bajo que el parser SQL pusiera mala cara.
  2. Cada migración que escribe el autor del plugin usa el prefijo. El scaffolder lo hardcodea: create_{vendor}_{name}_settings_table para la migración incluida. Las tablas nuevas siguen: {vendor}_{name}_<su_tabla>. Ejemplos de acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.

El vendor acelle usa una convención algo más laxa: sus tablas se prefijan solo por el nombre del plugin (ai) en lugar de acelle_ai, porque acelle es el propio vendor host. Los plugins de terceros deberían usar el prefijo completo {vendor}_{name} para dejar espacio a cualquier futuro plugin de primera línea o del vendor host sin colisionar.

Su primera migración + modelo

La migración de settings del esqueleto es suficiente para aprender. Usa el builder estándar Schema de Laravel sin envoltorios específicos de plugin:

// storage/app/plugins/acmecorp/loyalty/database/migrations/2000_01_01_000000_create_acmecorp_loyalty_settings_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('acmecorp_loyalty_settings', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->text('value')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('acmecorp_loyalty_settings');
    }
};

El modelo coincidente vive en src/Models/Setting.php dentro del plugin y se vincula explícitamente al nombre de tabla prefijado:

namespace Acmecorp\Loyalty\Models;

use Illuminate\Database\Eloquent\Model;

class Setting extends Model
{
    protected $table = 'acmecorp_loyalty_settings';
    protected $fillable = ['name', 'value'];
}

Ponga siempre $table explícitamente: la pluralización snake_case por defecto de Laravel (Acmecorp\Loyalty\Models\Settingsettings) apuntaría a una tabla que no existe (o, peor, a la tabla settings del host si existe una).

Claves foráneas a tablas del core

Las tablas del plugin habitualmente hacen referencia a las tablas customers, users u otras tablas de dominio del host. Añada las claves foráneas a la manera estándar de Laravel: viven en su migración, apuntan a la tabla del host y siguen los tipos de columna del host:

$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
      ->references('id')->on('customers')
      ->onDelete('set null');

Dos notas operativas que conviene recordar. Primero, mantenga la FK nullable cuando la relación sea opcional: onDelete('set null') lo requiere. Segundo, no haga cascade en las eliminaciones de las tablas del host a menos que los datos de su plugin deban seguir cuando un admin elimine un cliente desde la UI del host. Un plugin de fidelidad que hiciera cascade sobre customers perdería silenciosamente todo el historial de puntos de cada cuenta cuando un admin elimine un solo cliente de prueba; soft-delete sobre su propia tabla o limpiar a través de un job de cola es normalmente la decisión correcta.

Ejemplo real — las catorce migraciones de acelle/ai

El plugin complejo canónico del código, storage/app/plugins/acelle/ai, entrega catorce migraciones contra trece tablas. Son un ejercicio de lectura útil para cualquiera que esté planificando un plugin con un esquema no trivial:

Nombre del archivoQué crea / cambia
2026_04_28_000001_create_ai_conversations_table.phpSesiones de chat de varios turnos — uid, FK de customer_id, enum de estado, rollups de tokens / coste
2026_04_28_000002_create_ai_messages_table.phpTurno único de usuario / agente — rol, JSON de contenido, FK a tool-call, latencia, modelo usado
2026_04_28_000003_create_ai_requests_table.phpUna fila por llamada a la API upstream — motor, hash del prompt, latencia, coste, error
2026_04_28_000004_create_ai_tool_calls_table.phpInvocaciones de function-call generadas por un turno del agente — nombre de la herramienta, JSON de entrada/salida
2026_04_28_000005_create_ai_feedback_table.phpValoraciones de pulgar arriba/abajo + texto libre por mensaje y por conversación
2026_04_28_000006_create_ai_raw_blobs_table.phpRespuestas brutas originales del proveedor, conservadas para replay / auditoría
2026_04_28_000007_create_ai_daily_rollup_table.phpAgregado por día para el panel de administración — totales de tokens, coste, tasa de error
2026_04_29_000001_add_client_message_id_to_ai_messages.phpColumna de dedupe entre pestañas — aditiva, sin valores por defecto
2026_04_30_000002_add_source_to_ai_tool_calls.phpRastrea si una llamada a herramienta vino de la ruta de agente o de la de soporte
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpArreglo del ancho de ULID / UUID — migración que altera columnas
2026_05_02_200000_create_ai_tool_undo_records_table.phpRastrea acciones de herramienta reversibles para la funcionalidad de «deshacer último»
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpAñade una columna JSON para la telemetría de URLs saneadas
2026_05_04_000001_create_ai_settings_table.phpSettings de administración a nivel de plugin — separados de plugins.data para que cada fila pueda indexarse

Varios patrones de esta lista se trasladan directamente a otros plugins. Separar «blobs brutos» en una tabla aparte de «resumen acumulado» permite que la tabla de rollup se mantenga lo bastante pequeña como para escanearla; las FKs anulables a customers + users permiten que la misma fila funcione para tráfico autenticado + anónimo; los rollups de una fila por día dan al panel de administración lecturas baratas sin un JOIN pesado contra las tablas de actividad.

Evolucionar el esquema más adelante

Después de que el plugin esté en producción con clientes activos, los cambios de esquema son rutinarios. El patrón es idéntico al de una app Laravel normal: coloque un archivo de migración nuevo con timestamp con la fecha actual en database/migrations/ y ejecute artisan migrate --path=.... Dos formas de disparar eso:

  • Camino frío (releases): desactive el plugin, despliegue el archivo de migración nuevo con el resto de la actualización del plugin y reactívelo. La reactivación dispara el evento activate_plugin_*, que ejecuta artisan migrate contra la ruta, que recoge los archivos nuevos.
  • Camino caliente (durante la operación normal): despliegue el archivo y luego llame a artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force directamente. El hook del ciclo de vida es cómodo, pero no es mágico: es solo un envoltorio sobre el mismo comando.

Las migraciones que alteran el esquema sobre tablas que el plugin comparte con el host (columnas con claves foráneas, vistas con JOIN) necesitan el mismo cuidado que cualquier migración en producción: columnas aditivas primero, desplegar, hacer backfill y luego eliminar. El ciclo de vida del plugin no cambia las reglas.

El flag $keepData — preservar datos entre reinstalaciones

Algunos plugins son dueños de datos que el admin no querría perder si el plugin se desinstala y se reinstala. Puntos de fidelidad de los clientes, historial de valoraciones de IA, logs de auditoría de la pasarela de pago: ninguno de estos pertenece al cubo de «revertir el esquema y olvidar». El ciclo de vida del plugin gestiona esto con un único argumento booleano que el host dispara a través del evento de delete:

// app/Model/Plugin.php — when the host deletes a plugin
public function deleteAndCleanup(bool $keepData = false)
{
    Hook::fire('delete_plugin_'.$this->name, [$keepData]);
    $this->deletePluginDirectory();
    $this->delete();
    self::updatePluginMasterFile($this->name, null);
}

El listener del plugin decide qué significa $keepData = true en su propio contexto. El patrón del esqueleto, saltarse el rollback por completo, es una opción. Un plugin más matizado podría revertir las tablas operativas pero preservar los datos de cara al cliente:

Hook::on('delete_plugin_acmecorp/loyalty', function ($keepData = false) {
    \Artisan::call('migrate:rollback', [
        '--path'  => 'storage/app/plugins/acmecorp/loyalty/database/migrations',
        '--force' => true,
    ]);

    if (! $keepData) {
        // Drop the customer-facing tables only when the admin
        // confirmed they want to start over.
        \Schema::dropIfExists('acmecorp_loyalty_accounts');
        \Schema::dropIfExists('acmecorp_loyalty_transactions');
    }
});

Si la UI del host muestra o no una casilla «conservar datos» es una decisión por host; el contrato está en su sitio en cualquier caso. Los plugins que no tengan nada que preservar pueden ignorar el argumento con el valor por defecto: function ($keepData = false) { ... } funciona pase o no el host el flag.

Cinco antipatrones

1. Escribir en las tablas settings, customers o users del host

Tentador porque el JOIN es una consulta menos, pero ata el plugin al esquema del host para siempre. Cualquier actualización del host que renombre una columna rompe el plugin silenciosamente. Solución: escriba en su propia tabla, FK a la tabla del host. El JOIN es barato, el acoplamiento queda laxo.

2. Olvidar $table en su modelo

Sin una propiedad $table explícita, Laravel pluraliza el nombre de la clase en minúsculas. Acmecorp\Loyalty\Models\Account resuelve a accounts, no a acmecorp_loyalty_accounts. Solución: ponga siempre protected $table = '{vendor}_{name}_<noun>' en los modelos del plugin.

3. Borrados con cascade en tablas del host

Un admin elimina un solo cliente de prueba; su plugin pierde cada fila relacionada. Solución: use onDelete('set null') en FKs opcionales, soft-delete sus propias filas en los borrados de la tabla del host a través de un job de cola, y reserve cascade para sus propias tablas hijas internas.

4. Hardcodear el --path de migración en register()

register() se ejecuta pronto: antes de que los helpers de ruta de almacenamiento del host sean fiables. Solución: el listener activate_plugin_* pertenece a boot(), donde storage_path() y compañía están conectados.

5. Mezclar migraciones de esquema y migraciones de datos en el mismo archivo

Una migración que crea una columna y luego rellena el valor en cada fila hace que la desactivación sea frágil: el rollback tiene que revertir también el backfill. Solución: divida en dos timestamps. La migración de esquema es inmediatamente reversible; la migración de datos es un archivo separado que la rutina de desactivación del plugin puede elegir volver a ejecutar, omitir o invertir.

A dónde ir después

Los modelos cubren la persistencia; la siguiente página es Traducciones, el flujo indirecto que permite a los admins editar las cadenas del plugin a través de la UI de Idiomas del host sin tocar nunca el código fuente del plugin. Después de eso, Ciclo de vida cubre la secuencia de cuatro estados boot/activate/disable/delete en profundidad, y Testing cubre la conexión de phpunit.xml para las suites de tests de plugins.