四种状态一览
每个插件行都带有一个 status 列,取值为 active 或 inactive 之一。另外还有两个隐式状态:尚未注册(无数据库行,无主文件项)以及已删除(文件目录已移除、行已删除、主文件项已删除)。四次状态转换在它们之间移动插件:
| 转换 | 方法 | 转换前状态 | 转换后状态 | 磁盘 / 数据库的变化 |
| Register | Plugin::register($name) | (无行) | inactive | 插入数据库行;写入主文件项;在当前请求中启动 service provider |
| Activate | $plugin->activate() | inactive | active | 通过 activate 钩子执行迁移;翻转数据库状态;主文件 error 清空 |
| Disable | $plugin->disable() | active | inactive | 翻转数据库状态;主文件 error 清空。仅此而已。 |
| Delete | $plugin->deleteAndCleanup($keepData = false) | 任意 | (无行) | 触发 delete 钩子(通常是 migrate:rollback);删除插件文件夹;删除数据库行;删除主文件项 |
值得记住的心智模型:register 与 delete 改变世界(磁盘文件、数据库 schema)。activate 与 disable 只翻转一个标志 — register 期间建立的路由、视图、hook 与 service-provider 状态都保留原状。后续章节按顺序详解每一次转换。
状态 1 — Register / 安装
app/Model/Plugin.php:559 处的 Plugin::register($name) 是入口。它会在 php artisan plugin:init 结束时以及每次通过管理员插件页面成功上传时自动被调用。该方法按顺序做五件事:
- 从
storage/app/plugins/{vendor}/{name}/ 读取 composer.json,把 title、description、version 复制到模型中。如果 composer 的 name 字段与目录不完全匹配则抛错。
- 向
plugins 数据库表插入(或更新)行,status = inactive。查询方式是 firstOrNew(['name' => $name]),所以重新注册一个已有插件会更新而不是重复。
- 写入主文件:
storage/app/plugins/index.json 会得到一个 { "name": { "status": "inactive" } } 项。这是宿主在每次请求都会读取、无需访问数据库的启动期注册表。
- 立即加载 service provider:
$plugin->load($withServiceProvider = true) 会用一个全新的 Composer\Autoload\ClassLoader 注册 PSR-4 前缀,并对插件的 service provider 类调用 App::register()。方法返回时,插件的路由、视图、hook 已经接入运行中的进程。
- 物化翻译并发布资源:
Language::dump() 在 storage/app/data/plugins/{vendor}/{name}/lang/ 下创建按语言的运行时文件,然后 artisan vendor:publish --force --tag=plugin 把任何打包资源复制到 public/plugins/{vendor}/{name}/。
register 之后,插件已安装并加载。它还未激活 — 仅仅意味着插件在 activate 事件上挂载的逻辑还没有运行。插件的路由、视图与 hook 监听器已经处于活跃状态。
状态 2 — Activate
Plugin.php:484 处的 $plugin->activate() 是管理员「Activate」按钮调用的方法。按顺序四步:
- 触发 activate 钩子:
Hook::fire('activate_plugin_'.$this->name)。每一个针对该名称注册的监听器都会执行 — 通常是插件自己注册的 Hook::on('activate_plugin_*', ...) 监听器,它对插件的迁移目录调用 artisan migrate。其他插件也可以在同一事件上注册额外监听器。
- 重新校验
composer.json:self::validateMetaData($config) 校验插件必需的键(name、version、app_version)存在且格式良好。缺键会在状态翻转之前抛错。
- 将数据库 status 设为
active 并保存行。
- 更新主文件:
{ "status": "active", "error": null } — error 重置会清掉之前任何启动失败,后续 autoload 扫描就会把插件视为健康。
激活在实践中是幂等的。对已激活插件再次执行 activate() 会重新触发钩子(所以执行 migrate 的监听器会再跑一次 — Laravel 的 migrations 表会对已运行的文件去重,所以第二次调用是空操作),重新校验,并写入相同状态。没有专门的「已经激活」分支。
状态 3 — Disable
Plugin.php:136 处的 $plugin->disable() 是四个方法中最简单的一个。它只做这些:
- 将数据库 status 设为
inactive。
- 用新状态更新主文件,并清掉任何
error 字段。
这就是整个方法的全部。它不会卸载任何东西。
插件 boot() 时注册的路由仍然处于注册状态。视图仍可挂载。当宿主触发 hook 时,hook 监听器仍会触发。插件的 service provider 仍然位于应用容器中,且下一次请求会再次加载,因为 autoloadWithoutDbQuery() 会从主文件读取每一项,不区分状态。Disable 是状态翻转,不是卸载 — Laravel 本身不支持在 service provider 启动后取消注册它。
这就是为什么 acelle/console 插件是「插件功能在停用时应消失」的规范模式:路由始终加载,但一个名为 console.active 的路由中间件会在 Plugin::getByName('acelle/console')->isActive() 返回 false 时以 404 终止。检查发生在每一次请求,依据当前数据库状态进行,所以停用插件后,从下一次请求起其路由就会返回 404。
三步实现「可见的停用」模式。(1)定义一个路由中间件,检查 Plugin::enabled('myvendor/myplugin'),false 时以 404 终止。(2)在您的 service provider 的 boot() 中将其注册为中间件别名。(3)在 routes.php 中将其应用到您的路由组。任何对用户暴露功能的插件都应遵循此模式 — 否则从用户视角看「已停用」与「已激活」看起来完全相同。
状态 4 — Delete
Plugin.php:670 处的 $plugin->deleteAndCleanup($keepData = false) 是完整拆除。按顺序四步:
- 触发 delete 钩子:
Hook::fire('delete_plugin_'.$name, [$keepData])。脚手架的监听器会对插件迁移目录调用 artisan migrate:rollback。$keepData 标志会被转发,监听器可以选择不回滚承载客户数据的表 — 实操模式见数据库与模型一页。
- 删除插件目录:
$this->deletePluginDirectory() 递归移除 storage/app/plugins/{vendor}/{name}/。这一步之后,插件的 PHP 源代码已从磁盘消失。
- 删除数据库行。
plugins 表中不再有指向此插件的引用。
- 移除主文件项:
updatePluginMasterFile($name, null) — null 是约定的信号,表示删除该项而非合并新字段。
在下次请求启动一个全新进程之前,插件的路由、视图与 hook 仍然驻留在内存中 — 进程内的 Laravel 容器没有「取消注册此插件 service provider」的概念。下一次请求读取(已缩减的)主文件,不加载该插件,内存状态会随上一次请求生命周期一并丢弃。
每次转换时的主文件
storage/app/plugins/index.json 是启动时的单一事实来源。上面的每一次转换都会写入它。观察生命周期的一个有效方式是看一个插件的项在每一步的样子:
// Before register: no entry.
{}
// After register:
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After activate:
{
"acmecorp/loyalty": { "status": "active" }
}
// After a boot failure (sticky until cleared by activate):
{
"acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}
// After disable (error cleared, status flipped):
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After delete: no entry.
{}
三个宿主侧方法管理该文件:updatePluginMasterFile($name, $params) 用于合并写入(第二个参数传 null 表示移除项);resetPluginMasterFile() 在文件与数据库失同步时根据 Plugin::all() 重建文件;getErroredPluginNames() 读取每一项并返回 error 字段非空的名称。
从异常状态恢复
生产中常见三种失败模式:
1. 主文件中的插件行陈旧或错误
常见于手动编辑、部分部署或恢复数据库快照之后。修复:运行 php artisan tinker 并调用 Plugin::resetPluginMasterFile()。该方法从数据库迭代 Plugin::all() 并从头重写 JSON 文件,保留 status 并清空每一个 error 字段。
2. 插件的 error 字段被设置,管理员插件页显示红色提示
错误是粘性的 — 当 autoloadWithoutDbQuery() 用 try/catch 包裹 loadPluginByName() 调用并捕获到异常时设置。错误会一直保留,直到一次成功的 activate()(设置 error => null)或一次 disable()(同样)。修复:解决底层问题(缺失 autoload.psr-4、命名空间不匹配、缺失 service provider 类),然后点击 Activate;下次启动就会成功,错误也会被清除。
3. 插件文件夹缺失但主文件项依然存在
发生在手动 rm -rf 之后。启动仍然会尝试通过主文件项加载插件,抛错,并记录错误。修复:用 Plugin::updatePluginMasterFile($name, null) 直接移除主文件项,或者 — 如果插件应继续存在 — 重新上传源码归档并再次运行 Plugin::register($name) 重新填充一切。
plugin:* 控制台命令
宿主只内置一个 artisan 命令:plugin:init。没有 plugin:activate、plugin:disable 或 plugin:delete 命令 — 这些都是管理员 UI 动作。程序化访问直接通过模型方法:
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate(); // → active, runs migration via activate hook
>>> $p->disable(); // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true); // → preserve customer-facing tables
这与管理员插件页面内部使用的界面相同。CI 脚本、seeder 与集成测试都会直接调用这些方法。测试深度文章讲解了测试套件层面的模式。
一图说尽状态转换
┌─────────────────────┐
│ not registered │ (no row, no master-file entry)
└──────────┬──────────┘
│ Plugin::register($name)
│ ├─ writes DB row (status=inactive)
│ ├─ writes master file
│ ├─ loads service provider in-process
│ └─ Language::dump() + vendor:publish
▼
┌─────────────────────┐
┌────▶ │ inactive │ ◀───┐
│ └──────────┬──────────┘ │
│ │ │
│ activate()│ │ disable()
│ │ │ ├─ status=inactive
│ │ │ └─ master file updated
│ ▼ │
│ ┌─────────────────────┐ │
│ │ active │ ────┘
│ └──────────┬──────────┘
│ │
│ deleteAndCleanup($keepData)
│ │ ├─ fires delete hook (rollback unless $keepData)
│ │ ├─ removes plugin folder
│ │ ├─ deletes DB row
│ │ └─ removes master-file entry
│ ▼
│ ┌─────────────────────┐
└──────│ not registered │
└─────────────────────┘
(cycle: register again to re-install)
五个反模式
1. 把 disable 当作能卸载插件
路由仍然注册,hook 仍然触发,视图仍然可挂载。修复:用 Plugin::enabled(...) 中间件或内联检查为用户可见功能把关,正如 acelle/console 那样。
2. 在生产环境手动编辑主文件
JSON 容易被破坏。修复:通过 tinker 调用 Plugin::updatePluginMasterFile() 或 Plugin::resetPluginMasterFile() — 二者都会校验。
3. rm -rf storage/app/plugins/{vendor}/{name} 而不移除主文件项
启动会持续尝试加载缺失的插件并记录错误。修复:移除文件夹时务必同时调用 Plugin::updatePluginMasterFile($name, null),或使用同时完成两者的 deleteAndCleanup()。
4. 在 service provider 的 boot() 中调用 activate()
boot 阶段每个进程运行一次;在那里调用 activate() 会让 activate 钩子每次请求都触发。迁移每次都跑(幂等 — 但很贵),副作用监听器也都会触发。修复:激活是管理员 UI 动作,绝不是 boot 时的副作用。
5. 忘记 register 发生在 activate 之前
一些插件尝试通过 activate 钩子监听器播种默认数据,并引用依赖插件自身迁移的 Eloquent 模型 — 但首次 activate 时迁移还未运行。修复:迁移监听器在 activate 期间运行,先于任何可能引用新表的其他 Hook::on('activate_plugin_*') 监听器。请安排注册顺序使迁移最先(在脚手架中已经如此 — 请保持这样)。
下一步阅读
生命周期讲述何时;测试讲述如何验证。下一页讲解 phpunit.xml 测试套件注册、PluginTestCase 基类模式、hooks-under-test 断言以及 activate-test-delete 的 CI 周期。