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:
- 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.
- 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\Setting → settings) 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 archivo | Qué crea / cambia |
2026_04_28_000001_create_ai_conversations_table.php | Sesiones 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.php | Turno único de usuario / agente — rol, JSON de contenido, FK a tool-call, latencia, modelo usado |
2026_04_28_000003_create_ai_requests_table.php | Una fila por llamada a la API upstream — motor, hash del prompt, latencia, coste, error |
2026_04_28_000004_create_ai_tool_calls_table.php | Invocaciones 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.php | Valoraciones de pulgar arriba/abajo + texto libre por mensaje y por conversación |
2026_04_28_000006_create_ai_raw_blobs_table.php | Respuestas brutas originales del proveedor, conservadas para replay / auditoría |
2026_04_28_000007_create_ai_daily_rollup_table.php | Agregado 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.php | Columna de dedupe entre pestañas — aditiva, sin valores por defecto |
2026_04_30_000002_add_source_to_ai_tool_calls.php | Rastrea 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.php | Arreglo del ancho de ULID / UUID — migración que altera columnas |
2026_05_02_200000_create_ai_tool_undo_records_table.php | Rastrea acciones de herramienta reversibles para la funcionalidad de «deshacer último» |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | Añade una columna JSON para la telemetría de URLs saneadas |
2026_05_04_000001_create_ai_settings_table.php | Settings 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.