为什么使用插件隔离的表
一个想要持久化状态的插件,可以直接使用宿主的 users、customers 或 plugins 表 — 但这些都无法在升级或重命名后存活。插件系统反而在同一个数据库中保留了一部分空间用于插件自有的表,通过命名隔离。这一设计带来三个特性:
- 同一安装上的两个插件永远不会冲突。每个插件的表都以该插件的
{vendor}_{name} 身份为前缀,校验器已将其约束为小写字母和数字。acmecorp/loyalty 的设置表是 acmecorp_loyalty_settings;otherteam/loyalty 的则是 otherteam_loyalty_settings。同名,不同前缀。
- 只有激活动作才会创建表。骨架的 service provider 监听每个插件的
activate_plugin_{vendor}/{name} 事件,并针对该插件自己的 migration 目录运行 artisan migrate。在管理员激活之前,插件的 namespace 已被自动加载,但其表并不存在。
- 删除可以是干净的。骨架的 service provider 同时监听
delete_plugin_{vendor}/{name} 并运行 migrate:rollback。那些拥有管理员希望在重装后保留的数据的插件,可以通过 $keepData 标志选择不回滚 — 参见下文。
Migration 存放位置
插件 migration 位于 storage/app/plugins/{vendor}/{name}/database/migrations/。它们不会进入宿主应用根目录下的 database/migrations/ 文件夹 — 二者完全分离。宿主的 php artisan migrate 永远不会看到它们,这就是为什么激活必须通过生命周期 Hook 显式完成这项工作。
骨架会生成一个以插件设置表命名的 migration,前缀时间戳为 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 排序规则在插件文件夹内同样适用,与宿主的顺序隔离。
激活时运行;删除时回滚
骨架的 src/ServiceProvider.php 包含两个生命周期监听器,将 migration 运行器与 activate / delete 事件接通。两者都应放在 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 绕过 artisan migrate 在 APP_ENV=production 时通常要求的生产确认提示;生命周期事件本身就是用户确认。
激活是幂等的 — 在已激活的插件上再次运行 migration 块是安全的。artisan migrate 会读取 Laravel 的 migrations 跟踪表并跳过已经运行过的文件。所以管理员两次点击 Activate(或者误触了 activate REST endpoint),最终都会得到相同状态。
带 vendor 前缀的表名
宿主代码库中的两条约定共同防止了冲突:
- 插件名本身受约束,必须匹配
^[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 vendor 使用稍微宽松一些的约定 — 它的表仅以插件名(ai)为前缀,而不是 acelle_ai,因为 acelle 本身就是宿主 vendor。第三方插件应当使用完整的 {vendor}_{name} 前缀,为未来任何第一方/宿主 vendor 插件留出空间,避免冲突。
您的第一个 migration 与模型
骨架的 settings 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');
有两条值得记住的运维要点。第一,当关系是可选时,让外键保持 nullable — onDelete('set null') 要求如此。第二,不要对宿主表使用 cascade 删除,除非您的插件数据应该跟随管理员通过宿主 UI 删除某个客户而一起消失。一个对 customers 级联的积分插件,在管理员仅删除一个测试客户时,会悄无声息地丢失每个账户的积分历史;通常正确的做法是在自己的表上软删除,或者通过队列任务清理。
真实示例 — acelle/ai 的十四个 migration
代码库中标准的复杂插件 storage/app/plugins/acelle/ai,针对 13 张表交付了 14 个 migration。对任何计划编写有非平凡 schema 插件的人来说,它们都是有用的阅读练习:
| 文件名 | 它创建/变更了什么 |
2026_04_28_000001_create_ai_conversations_table.php | 多轮对话会话 — uid、customer_id 外键、status 枚举、token / 成本汇总 |
2026_04_28_000002_create_ai_messages_table.php | 单次用户/智能体回合 — 角色、content JSON、tool-call 外键、延迟、所用模型 |
2026_04_28_000003_create_ai_requests_table.php | 上游 API 每次调用一行 — engine、prompt 哈希、延迟、成本、错误 |
2026_04_28_000004_create_ai_tool_calls_table.php | 由智能体回合触发的函数调用 — 工具名称、输入/输出 JSON |
2026_04_28_000005_create_ai_feedback_table.php | 每条消息和每个对话的点赞/点踩 + 自由文本反馈 |
2026_04_28_000006_create_ai_raw_blobs_table.php | 原始的提供方响应,保留用于重放/审计 |
2026_04_28_000007_create_ai_daily_rollup_table.php | 管理员仪表盘的每日聚合 — token 总量、成本、错误率 |
2026_04_29_000001_add_client_message_id_to_ai_messages.php | 跨表去重列 — 增量式,无默认值 |
2026_04_30_000002_add_source_to_ai_tool_calls.php | 跟踪 tool call 是来自 agent 路由还是 support 路由 |
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 分离,以便每一行都可被索引 |
此清单中的若干模式可以直接复用到其他插件中。将"原始 blob"拆分到与"汇总摘要"不同的表,能让汇总表保持足够小以便扫描;指向 customers + users 的可为空外键,让同一行既能用于已认证流量也能用于匿名流量;每日一行的汇总让管理员仪表盘能廉价读取,而无需对活动表做沉重的 JOIN。
后续 schema 演进
当插件在生产环境运行、已有活跃客户后,schema 变更就成了家常便饭。模式与普通 Laravel 应用完全相同 — 将一个带当前日期时间戳的新 migration 文件放入 database/migrations/,然后运行 artisan migrate --path=...。有两种触发方式:
- 冷路径(发布):停用插件,将新的 migration 文件随插件更新一起部署,然后重新激活。重新激活会触发
activate_plugin_* 事件,该事件针对路径运行 artisan migrate,自然就会拾取新文件。
- 热路径(正常运行期间):部署文件,然后直接调用
artisan migrate --path=storage/app/plugins/{vendor}/{name}/database/migrations --force。生命周期 Hook 很方便,但没有什么神奇之处 — 它只是同一命令的封装。
对插件与宿主共享的表(带外键的列、被 JOIN 的视图)所做的 schema 变更,需要遵循任何生产 migration 都需要的同样小心 — 先增量增列、再部署、回填,最后再删除。插件生命周期并未改变这些规则。
$keepData 标志 — 在重装之间保留数据
某些插件拥有的数据是管理员在卸载并重装后不希望丢失的。客户积分、AI 反馈历史、支付网关审计日志 — 这些都不属于"回滚 schema 然后遗忘"那一类。插件生命周期通过宿主在 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);
}
插件的监听器自己决定 $keepData = true 在其自身上下文中意味着什么。骨架的做法 — 完全跳过回滚 — 是其中一种选项。更精细的插件可能会回滚运营性表,但保留面向客户的数据:
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) { ... } 无论宿主是否传入该标志都能工作。
五个反模式
1. 写入宿主的 settings、customers 或 users 表
诱人,因为可以少做一次 JOIN,但这会把插件永久地烧进宿主的 schema 里。宿主升级时任何重命名列的操作都会悄无声息地破坏插件。修复:写入您自己的表,外键指向宿主表。JOIN 很便宜,耦合度保持松散。
2. 忘记在模型上设置 $table
没有显式 $table 属性时,Laravel 会将类名小写后复数化。Acmecorp\Loyalty\Models\Account 解析为 accounts,而非 acmecorp_loyalty_accounts。修复:始终在插件模型上设置 protected $table = '{vendor}_{name}_'。
3. 对宿主表执行 cascade 删除
管理员删除一个测试客户;您的插件失去所有相关行。修复:对可选外键使用 onDelete('set null'),通过队列任务在宿主表删除时软删除您自己的行,并把 cascade 留给您自己的内部子表。
4. 在 register() 中硬编码 migration 的 --path
register() 运行得很早 — 早于宿主的存储路径辅助函数变得可靠之前。修复:activate_plugin_* 监听器应放在 boot() 中,那里 storage_path() 等才已就绪。
5. 在同一个文件中混合 schema migration 与数据 migration
一个既创建列又把值回填到每一行的 migration 会让停用变得脆弱 — 回滚时必须把回填也反向做掉。修复:拆成两个时间戳。schema migration 可立即回滚;数据 migration 是一个独立文件,插件的停用流程可以选择重新运行、跳过或反向。
下一步去哪里
模型覆盖了持久化;下一页是 翻译,这一间接流程让管理员能通过宿主的 Languages UI 编辑插件字符串,而完全不必触碰插件源代码。在那之后,生命周期会深入讲解 boot/activate/disable/delete 四状态序列,测试则讲解插件 test suite 在 phpunit.xml 中的接线方式。