Tables plugin-isoladas. Names com vendor-prefix. Auto migrate no activate.

Os dados de um plugin vivem nas próprias tables dele, no mesmo banco que a aplicação host usa, mas com names prefixados pela identidade {vendor}_{name} do plugin para que dois plugins nunca colidam. Migrations vivem dentro do folder do plugin, rodam quando o admin clica em Activate, e fazem rollback quando o admin clica em Delete. Há uma única knob canônica — a flag $keepData — para plugins que possuem dados de usuário que o admin quer preservar através de re-installs.

Por que tables plugin-isoladas

Um plugin que quer state persistente poderia usar as tables users, customers, ou plugins do host — nada disso sobreviveria a um upgrade ou rename. O sistema de plugins reserva no lugar uma porção do mesmo banco para tables plugin-owned, isoladas por name. Três propriedades caem dessa decisão:

  • Dois plugins no mesmo install nunca colidem. As tables de todo plugin são prefixadas com a identidade {vendor}_{name} do plugin, que o validator já constrange para letras minúsculas e dígitos. A settings table do acmecorp/loyalty é acmecorp_loyalty_settings; a do otherteam/loyalty é otherteam_loyalty_settings. Mesmo name, prefixos diferentes.
  • Ativação é a única coisa que cria tables. O service provider do esqueleto escuta o evento per-plugin activate_plugin_{vendor}/{name} e roda artisan migrate contra o folder próprio de migrations do plugin. Até um admin ativar, o namespace do plugin é autoloaded mas as tables dele não existem.
  • Deleção pode ser limpa. O service provider do esqueleto também escuta delete_plugin_{vendor}/{name} e roda migrate:rollback. Plugins que possuem dados que o admin quer manter através de re-installs podem optar por sair via a flag $keepData — veja abaixo.

Onde migrations vivem

Migrations de plugin vivem em storage/app/plugins/{vendor}/{name}/database/migrations/. Elas não vão para o folder database/migrations/ raiz da aplicação host — elas são completamente separadas. O php artisan migrate do host nunca olha para elas, e é por isso que a ativação tem que fazer o trabalho explicitamente pelo hook de lifecycle.

O esqueleto scaffolda uma migration nomeada como a settings table do plugin, com um prefixo timestamp 2000_01_01_000000_:

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

O prefixo deliberado 2000_01_01 ordena a migration do scaffold primeiro. Migrations reais que você adiciona depois ganham timestamps com data atual e rodam em ordem cronológica atrás dela — as regras normais de ordenação de migration do Laravel aplicam dentro do folder do plugin, isoladas da ordem do host.

Activate roda elas; delete faz rollback

O src/ServiceProvider.php do esqueleto contém dois listeners de lifecycle que conectam o migration runner aos eventos activate / delete. Ambos pertencem 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,
    ]);
});

A opção --path diz para artisan migrate operar contra esse folder apenas — deixa as migrations do host e as migrations de qualquer outro plugin intocadas. --force bypassa o prompt de confirmação de produção que artisan migrate normalmente requer quando APP_ENV=production; o evento de lifecycle é em si a confirmação do usuário.

Ativação é idempotente — rodar o bloco de migration de novo num plugin já ativado é seguro. artisan migrate lê a tracking table migrations do Laravel e pula arquivos que ele já rodou. Então um admin que clica em Activate duas vezes (ou bate no endpoint REST activate por acidente) ganha o mesmo end state.

Table names com vendor-prefix

Duas convenções no codebase do host juntas previnem colisões:

  1. O próprio name do plugin é constrangido a ^[a-z0-9]+\/[a-z0-9]+$ com 2-32 chars por lado. Então {vendor}_{name} como prefixo nunca contém slash, dash, ou underscore que o parser SQL fosse reclamar.
  2. Toda migration que o autor do plugin escreve usa o prefixo. O scaffolder hardcoda isso — create_{vendor}_{name}_settings_table para a migration empacotada. Tables novas seguem: {vendor}_{name}_. Exemplos de acelle/ai: ai_conversations, ai_messages, ai_requests, ai_tool_calls, ai_feedback.

O vendor acelle usa uma convenção um pouco mais frouxa — suas tables são prefixadas apenas pelo plugin name (ai) em vez de acelle_ai, porque acelle em si é o vendor host. Plugins third-party devem usar o prefixo {vendor}_{name} completo para deixar espaço para qualquer futuro plugin first-party / host-vendor sem colidir.

Sua primeira migration + model

A migration de settings do esqueleto é suficiente para aprender. Ela usa o builder Schema padrão do Laravel sem wrappers plugin-specific:

// 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');
    }
};

O model correspondente vive em src/Models/Setting.php no plugin e binda explicitamente ao table name prefixado:

namespace Acmecorp\Loyalty\Models;

use Illuminate\Database\Eloquent\Model;

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

Sempre seta $table explicitamente — a pluralização snake_cased default do Laravel (Acmecorp\Loyalty\Models\Settingsettings) apontaria para uma table que não existe (ou, pior, para a table settings do host se uma existir).

Foreign keys para tables do core

Tables de plugin rotineiramente referenciam customers, users, ou outras domain tables do host. Adicione foreign keys do jeito Laravel padrão — elas vivem na sua migration, apontam para a table do host, e seguem os tipos de coluna do host:

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

Duas notas operacionais que vale lembrar. Primeiro, mantenha o FK nullable quando o relacionamento é opcionalonDelete('set null') requer isso. Segundo, não use cascade em deleções nas tables do host a menos que os dados do seu plugin devam seguir quando um admin deleta um customer pela UI do host. Um plugin de loyalty que faz cascade em customers silenciosamente perderia o histórico de pontos de toda conta quando um admin remove um único customer de teste; soft-deletando na sua própria table ou scrubbing por um queue job é geralmente a chamada certa.

Exemplo real — as catorze migrations do acelle/ai

O plugin complexo canônico no codebase, storage/app/plugins/acelle/ai, entrega catorze migrations contra treze tables. Elas são um exercício de leitura útil para qualquer um planejando um plugin com schema não-trivial:

FilenameO que ela cria / muda
2026_04_28_000001_create_ai_conversations_table.phpSessions de chat multi-turn — uid, FK customer_id, enum status, rollups de token / cost
2026_04_28_000002_create_ai_messages_table.phpTurn único de user / agent — role, content JSON, FK tool-call, latência, model usado
2026_04_28_000003_create_ai_requests_table.phpUma row por chamada de API upstream — engine, prompt hash, latência, cost, error
2026_04_28_000004_create_ai_tool_calls_table.phpInvocações de function-call disparadas por um turn de agent — tool name, input/output JSON
2026_04_28_000005_create_ai_feedback_table.phpThumbs-up/down + feedback free-text por mensagem + por conversation
2026_04_28_000006_create_ai_raw_blobs_table.phpResponses raw originais do provider, mantidos para replay / audit
2026_04_28_000007_create_ai_daily_rollup_table.phpAgregado per-day para o dashboard admin — totais de token, cost, error rate
2026_04_29_000001_add_client_message_id_to_ai_messages.phpColuna de dedup cross-tab — aditiva, sem defaults
2026_04_30_000002_add_source_to_ai_tool_calls.phpRastreia se uma tool call veio da rota agent vs support
2026_05_02_180000_widen_ai_conversations_client_session_uid.phpFix de largura ULID / UUID — migration column-altering
2026_05_02_200000_create_ai_tool_undo_records_table.phpRastreia ações de tool reversíveis para a feature "undo last"
2026_05_03_000001_add_url_sanitization_to_ai_requests.phpAdiciona uma coluna JSON para telemetria de URL sanitizada
2026_05_04_000001_create_ai_settings_table.phpSettings admin plugin-level — mantidos separados de plugins.data para que cada row possa ser indexada

Vários padrões dessa lista se traduzem diretamente em outros plugins. Dividir "blobs raw" numa table separada de "summary rolled-up" deixa a rollup table pequena o suficiente para escanear; FKs nullable para customers + users deixam a mesma row funcionar para tráfego authenticated + anonymous; rollups one-row-per-day dão ao dashboard admin reads baratos sem um JOIN pesado contra as activity tables.

Evoluindo o schema depois

Depois que o plugin está em produção com customers ativos, mudanças de schema são rotineiras. O padrão é idêntico a um app Laravel normal — solte um arquivo de migration novo com timestamp de data atual em database/migrations/, depois rode artisan migrate --path=.... Duas maneiras de disparar isso:

  • Cold path (releases): desative o plugin, deploy do arquivo de migration novo com o resto do update do plugin, reative. Reativação dispara o evento activate_plugin_*, que roda artisan migrate contra o path, que pega arquivos novos.
  • Hot path (durante operação normal): deploy do arquivo, depois chame artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force diretamente. O hook de lifecycle é conveniente mas não mágico — é só um wrapper em volta do mesmo comando.

Migrations schema-altering em tables que o plugin compartilha com o host (colunas com foreign-key, views joined) precisam do mesmo cuidado que qualquer migration de produção precisa — colunas aditivas primeiro, deploy, backfill, depois drop. O lifecycle do plugin não muda as regras.

A flag $keepData — preservando dados através de re-installs

Alguns plugins possuem dados que o admin não gostaria de perder se o plugin for desinstalado e re-instalado. Pontos de loyalty de customer, histórico de feedback de AI, audit logs de payment-gateway — nenhum desses pertence ao bucket "rollback do schema e esquece". O lifecycle do plugin lida com isso com um único argumento boolean que o host dispara pelo evento 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);
}

O listener do plugin decide o que $keepData = true significa no contexto dele. O padrão do esqueleto — pular o rollback inteiramente — é uma opção. Um plugin mais nuançado pode fazer rollback das tables operacionais mas preservar dados customer-facing:

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');
    }
});

Se a UI do host expõe um checkbox "keep data" é uma decisão per-host; o contrato está no lugar de qualquer jeito. Plugins que não têm nada para preservar podem ignorar o argumento com o default — function ($keepData = false) { ... } funciona se o host passa a flag ou não.

Cinco anti-padrões

1. Escrevendo nas tables settings, customers, ou users do host

Tentador porque o join é uma query a menos, mas baked o plugin no schema do host para sempre. Qualquer upgrade do host que renomeia uma coluna quebra o plugin silenciosamente. Fix: escreva na sua própria table, FK para a table do host. O join é barato, o acoplamento fica loose.

2. Esquecendo $table no seu model

Sem uma propriedade $table explícita, Laravel pluraliza o class name lowercased. Acmecorp\Loyalty\Models\Account resolve para accounts, não acmecorp_loyalty_accounts. Fix: sempre seta protected $table = '{vendor}_{name}_' em models de plugin.

3. Deletes em cascade em tables do host

Um admin deleta um único customer de teste; seu plugin perde toda row relacionada. Fix: use onDelete('set null') em FKs opcionais, soft-delete suas próprias rows em deletes de tables do host por um queue job, e reserve cascade para suas próprias child tables internas.

4. Hardcoding o --path da migration em register()

register() roda cedo — antes dos helpers de storage path do host serem confiáveis. Fix: o listener activate_plugin_* pertence em boot(), onde storage_path() e amigos estão conectados.

5. Misturando migrations de schema e migrations de data no mesmo arquivo

Uma migration que cria uma coluna e depois backfilla o valor em toda row torna a desativação flaky — fazer rollback tem que reverter o backfill também. Fix: divida em dois timestamps. A migration de schema é imediatamente reversível; a migration de data é um arquivo separado que a rotina de desativação do plugin pode escolher re-rodar, pular, ou inverter.

Para onde ir em seguida

Models cobrem persistência; a próxima página é Traduções, o fluxo indireto que deixa admins editar strings do plugin pela UI Languages do host sem nunca tocar no source do plugin. Depois disso, Ciclo de vida cobre a sequência de quatro estados boot/activate/disable/delete em profundidade, e Testes cobre o wiring de phpunit.xml para test suites de plugin.