なぜプラグイン分離テーブルなのか
永続状態を持ちたいプラグインがホストの users、customers、plugins テーブルを使うことは可能です — しかしそれらはアップグレードやリネームを生き延びません。代わりにプラグインシステムは、同じデータベース内の一画をプラグイン所有のテーブル用に予約し、名前で分離します。この決定から 3 つの性質が導かれます:
- 同じインストール上の 2 つのプラグインは決して衝突しません。 すべてのプラグインのテーブルは、バリデータが小文字英数字に制約しているプラグインの
{vendor}_{name} 識別でプレフィックスされます。acmecorp/loyalty の設定テーブルは acmecorp_loyalty_settings、otherteam/loyalty は otherteam_loyalty_settings。名前は同じでもプレフィックスが異なります。
- テーブルを作成するのは activate のみです。 スケルトンのサービスプロバイダはプラグインごとの
activate_plugin_{vendor}/{name} イベントをリッスンし、プラグイン専用の migrations フォルダに対して artisan migrate を実行します。管理者が activate するまで、プラグインのネームスペースはオートロードされますがテーブルは存在しません。
- 削除はクリーンに行えます。 スケルトンのサービスプロバイダは
delete_plugin_{vendor}/{name} もリッスンし、migrate:rollback を実行します。再インストールをまたいでデータを保持したいプラグインは $keepData フラグでオプトアウトできます — 下記を参照してください。
Migration の置き場所
プラグインの Migration は storage/app/plugins/{vendor}/{name}/database/migrations/ に置かれます。ホストアプリケーションのルート database/migrations/ フォルダには 置きません — 完全に分離されています。ホストの php artisan migrate はそれらを一切見ないので、activate がライフサイクルフックを通じて明示的に実行する必要があるのです。
スケルトンは、プラグインの設定テーブルにちなんで命名された Migration を 1 つ、2000_01_01_000000_ のタイムスタンププレフィックス付きでスキャフォールドします:
storage/app/plugins/acmecorp/loyalty/
└── database/
└── migrations/
└── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
意図的な 2000_01_01 プレフィックスにより、スキャフォールド Migration が最初にソートされます。後から追加する実 Migration は現在日付のタイムスタンプを得て、その後に時系列順で実行されます — Laravel の通常の Migration 順序ルールがプラグインフォルダ内で適用され、ホストの順序とは分離されます。
activate が実行し、delete がロールバックする
スケルトンの src/ServiceProvider.php には、Migration ランナーを activate / delete イベントに結線する 2 つのライフサイクルリスナーが含まれます。どちらも 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,
]);
});
--path オプションは artisan migrate に対し、このフォルダのみを対象に動作するよう指示します — ホストの Migration も他プラグインの Migration も触れません。--force は、APP_ENV=production のときに artisan migrate が通常要求する本番確認プロンプトを回避します。ライフサイクルイベント自体がユーザーの確認に相当します。
activate は冪等です — すでに activate 済みのプラグインに対して Migration ブロックを再実行しても安全です。artisan migrate は Laravel の migrations トラッキングテーブルを読んで、既に実行したファイルをスキップします。したがって、管理者が Activate を 2 回クリックしても (または activate REST エンドポイントを誤ってヒットしても) 同じ最終状態になります。
ベンダープレフィックス付きテーブル名
ホストコードベースの 2 つの慣習が組み合わさって衝突を防いでいます:
- プラグイン名自体が制約されており、
^[a-z0-9]+\/[a-z0-9]+$ で各辺 2〜32 文字です。したがって {vendor}_{name} をプレフィックスとして使っても、SQL パーサが嫌うスラッシュ、ハイフン、アンダースコアは含まれません。
- プラグイン作者が書くすべての Migration はこのプレフィックスを使います。 スキャフォルダはこれをハードコードしています — 同梱 Migration は
create_{vendor}_{name}_settings_table。新規テーブルもこれに倣います: {vendor}_{name}_。acelle/ai の例: ai_conversations、ai_messages、ai_requests、ai_tool_calls、ai_feedback。
acelle ベンダーはやや緩い慣習を使っています — テーブルは acelle_ai ではなくプラグイン名 (ai) のみでプレフィックスされます。これは acelle 自体がホストベンダーであるためです。サードパーティプラグインは、将来のファーストパーティ/ホストベンダープラグインと衝突しないよう、完全な {vendor}_{name} プレフィックスを使うべきです。
最初の Migration とモデル
スケルトンの設定 Migration は学習教材として十分です。プラグイン固有のラッパーは使わず、標準の Laravel Schema ビルダーを使います:
// 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');
}
};
対応するモデルはプラグインの src/Models/Setting.php に置かれ、プレフィックス付きテーブル名へ明示的にバインドします:
namespace Acmecorp\Loyalty\Models;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model
{
protected $table = 'acmecorp_loyalty_settings';
protected $fillable = ['name', 'value'];
}
$table は必ず明示的に設定してください — Laravel のデフォルトの snake_case 複数形化 (Acmecorp\Loyalty\Models\Setting → settings) は存在しないテーブル (またはさらに悪いケースとして、ホスト側に settings テーブルが存在する場合はそちら) を指してしまいます。
コアテーブルへの外部キー
プラグインテーブルは日常的にホストの customers、users その他のドメインテーブルを参照します。外部キーは標準的な Laravel のやり方で追加します — Migration 内に書き、ホストテーブルを指し、ホストのカラム型に従います:
$table->unsignedBigInteger('customer_id')->nullable()->index();
$table->foreign('customer_id')
->references('id')->on('customers')
->onDelete('set null');
記憶しておくべき運用上の注意が 2 つあります。第 1 に、リレーションがオプショナルなら FK を nullable に保つこと — onDelete('set null') がそれを要求します。第 2 に、ホストテーブルの削除を cascade しないこと。ただし、管理者がホスト UI で顧客を削除したときにプラグインのデータも追従すべきケースは例外です。customers に対して cascade するロイヤリティプラグインは、管理者が 1 人のテストカスタマーを削除しただけで、全アカウントのポイント履歴をサイレントに失います。自前テーブルでのソフトデリート、または Queue ジョブでの整理が通常は正解です。
実例 — acelle/ai の 14 個の Migration
コードベース内の正典的に複雑なプラグイン storage/app/plugins/acelle/ai は、13 個のテーブルに対して 14 個の Migration を提供しています。非自明なスキーマを持つプラグインを計画する人にとって、有益な読解演習になります:
| ファイル名 | 何を作成/変更するか |
2026_04_28_000001_create_ai_conversations_table.php | マルチターンチャットセッション — uid、customer_id FK、ステータス enum、トークン/コストのロールアップ |
2026_04_28_000002_create_ai_messages_table.php | 単一のユーザー/エージェントターン — role、content JSON、tool-call FK、レイテンシ、使用モデル |
2026_04_28_000003_create_ai_requests_table.php | 上流 API 呼び出し 1 件につき 1 行 — engine、prompt hash、レイテンシ、コスト、エラー |
2026_04_28_000004_create_ai_tool_calls_table.php | エージェントターンから派生した関数呼び出し — tool name、input/output JSON |
2026_04_28_000005_create_ai_feedback_table.php | メッセージ単位 + 会話単位の thumbs-up/down と自由記述フィードバック |
2026_04_28_000006_create_ai_raw_blobs_table.php | 元のプロバイダー応答のロウデータ、リプレイ/監査用に保存 |
2026_04_28_000007_create_ai_daily_rollup_table.php | 管理ダッシュボード向けの日次集計 — トークン合計、コスト、エラー率 |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | クロスタブ重複排除用カラム — 加算的、デフォルトなし |
2026_04_30_000002_add_source_to_ai_tool_calls.php | ツール呼び出しがエージェントとサポートのどちらの経路から来たかを追跡 |
2026_05_02_180000_widen_ai_conversations_client_session_uid.php | ULID / UUID 幅の修正 — カラム改変 Migration |
2026_05_02_200000_create_ai_tool_undo_records_table.php | 「直前を取り消す」機能のために、可逆なツール操作を追跡 |
2026_05_03_000001_add_url_sanitization_to_ai_requests.php | サニタイズ済み URL テレメトリ用の JSON カラムを追加 |
2026_05_04_000001_create_ai_settings_table.php | プラグインレベルの管理者設定 — 行単位でインデックスできるよう plugins.data から分離して保持 |
このリストのいくつかのパターンは、他のプラグインへ直接転用できます。「ロウブロブ」を「集計サマリー」と別テーブルに分離することで集計テーブルをスキャン可能な小ささに保てます。customers + users への nullable FK により、同じ行が認証済み/匿名トラフィックの両方に使えます。1 日 1 行のロールアップにより、管理ダッシュボードはアクティビティテーブルへの重い JOIN なしに安価な読み取りができます。
後からスキーマを進化させる
プラグインがアクティブカスタマーとともに本番稼働した後でも、スキーマ変更は日常的に発生します。パターンは通常の Laravel アプリと同一です — 現在日付タイムスタンプの新規 Migration ファイルを database/migrations/ に置き、artisan migrate --path=... を実行します。トリガー方法は 2 つ:
- コールドパス (リリース時): プラグインを deactivate し、新しい Migration ファイルをプラグインアップデートの残りと一緒にデプロイし、再 activate します。再 activate により
activate_plugin_* イベントが発火し、artisan migrate がそのパスに対して実行され、新しいファイルを取り込みます。
- ホットパス (通常運用中): ファイルをデプロイした後、
artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force を直接呼びます。ライフサイクルフックは便利ですが魔法ではありません — 同じコマンドのラッパーにすぎません。
プラグインがホストと共有するテーブル (外部キー付きカラム、JOIN ビュー) に対するスキーマ改変 Migration は、通常の本番 Migration に必要な配慮を同じく要します — 加算カラムを先にデプロイ、バックフィル、最後に削除。プラグインのライフサイクルがそのルールを変えるわけではありません。
$keepData フラグ — 再インストールをまたいでデータを保持する
一部のプラグインは、アンインストールして再インストールしたら失いたくないデータを保有します。顧客のロイヤリティポイント、AI フィードバック履歴、決済ゲートウェイの監査ログ — これらはどれも「スキーマをロールバックして忘れる」バケツには属しません。プラグインライフサイクルは、ホストが delete イベントを通じて発火する単一の boolean 引数でこれを扱います:
// 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);
}
プラグインのリスナーが、自身の文脈で $keepData = true が何を意味するかを決めます。スケルトンのパターン — ロールバックを丸ごとスキップする — は 1 つの選択肢です。よりきめ細かいプラグインは、運用テーブルはロールバックしつつ顧客向けデータは保持することもできます:
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');
}
});
ホスト UI に「データを保持する」チェックボックスを露出するかどうかはホストごとの判断ですが、契約はいずれにせよ整っています。保持すべきデータがないプラグインは、デフォルトのまま引数を無視できます — function ($keepData = false) { ... } はホストがフラグを渡しても渡さなくても動きます。
5 つのアンチパターン
1. ホストの settings、customers、users テーブルに書き込む
JOIN が 1 つ減るので誘惑されますが、プラグインをホストのスキーマに永久に焼き付けることになります。ホスト側でカラム名を変えるアップグレードがあると、プラグインがサイレントに壊れます。修正: 自前のテーブルに書き、ホストテーブルへ FK を張ります。JOIN は安く、結合は緩く保てます。
2. モデルで $table を忘れる
明示的な $table プロパティがないと、Laravel はクラス名を小文字化して複数形にします。Acmecorp\Loyalty\Models\Account は acmecorp_loyalty_accounts ではなく accounts に解決されます。修正: プラグインのモデルには常に protected $table = '{vendor}_{name}_' を設定してください。
3. ホストテーブルでの cascade 削除
管理者が 1 人のテストカスタマーを削除しただけで、プラグインは関連する全行を失います。修正: オプショナルな FK には onDelete('set null') を使い、ホストテーブル削除時の自前行のソフトデリートは Queue ジョブで行い、cascade は自前の内部子テーブルにのみ予約してください。
4. register() で Migration の --path をハードコードする
register() は早期に走ります — ホストのストレージパスヘルパが信頼できる状態になる前です。修正: activate_plugin_* リスナーは boot() に置いてください。そこなら storage_path() などが結線済みです。
5. スキーマ Migration とデータ Migration を同じファイルに混在させる
カラムを作成して全行に値をバックフィルする Migration は、deactivate を不安定にします — ロールバックがバックフィルも逆実行する必要があるためです。修正: 2 つのタイムスタンプに分割します。スキーマ Migration は即座に可逆、データ Migration は別ファイルで、プラグインの deactivate ルーチンが再実行・スキップ・反転を選べるようにします。
次に読むページ
モデルは永続化を扱います。次のページは 翻訳 で、プラグインソースに一切触れずに管理者がホストの Languages UI でプラグイン文字列を編集できるようにする間接フローを扱います。その後、ライフサイクル が boot/activate/disable/delete の 4 状態シーケンスを深掘りし、テスト がプラグインテストスイート用の phpunit.xml 結線を扱います。