四种状态。四个模型方法。一个 JSON 主文件。

宿主应用中的每个插件都会经历四种离散状态:register(磁盘文件 + autoload + 数据库行)、activate(迁移 + 状态翻转)、disable(仅状态翻转 — 路由与 hook 保留)、delete(回滚 + 删除数据库行 + 主文件清理)。每一种状态都由 app/Model/Plugin.php 中的单个方法实现;每一次状态转换都会同时写入 plugins 数据库表与 storage/app/plugins/index.json。本页按顺序走过每一个状态,给出确切的宿主侧步骤。

四种状态一览

每个插件行都带有一个 status 列,取值为 activeinactive 之一。另外还有两个隐式状态:尚未注册(无数据库行,无主文件项)以及已删除(文件目录已移除、行已删除、主文件项已删除)。四次状态转换在它们之间移动插件:

转换方法转换前状态转换后状态磁盘 / 数据库的变化
RegisterPlugin::register($name)(无行)inactive插入数据库行;写入主文件项;在当前请求中启动 service provider
Activate$plugin->activate()inactiveactive通过 activate 钩子执行迁移;翻转数据库状态;主文件 error 清空
Disable$plugin->disable()activeinactive翻转数据库状态;主文件 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 结束时以及每次通过管理员插件页面成功上传时自动被调用。该方法按顺序做五件事:

  1. storage/app/plugins/{vendor}/{name}/ 读取 composer.json,把 titledescriptionversion 复制到模型中。如果 composer 的 name 字段与目录不完全匹配则抛错。
  2. plugins 数据库表插入(或更新)行status = inactive。查询方式是 firstOrNew(['name' => $name]),所以重新注册一个已有插件会更新而不是重复。
  3. 写入主文件:storage/app/plugins/index.json 会得到一个 { "name": { "status": "inactive" } } 项。这是宿主在每次请求都会读取、无需访问数据库的启动期注册表。
  4. 立即加载 service provider:$plugin->load($withServiceProvider = true) 会用一个全新的 Composer\Autoload\ClassLoader 注册 PSR-4 前缀,并对插件的 service provider 类调用 App::register()。方法返回时,插件的路由、视图、hook 已经接入运行中的进程。
  5. 物化翻译并发布资源: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」按钮调用的方法。按顺序四步:

  1. 触发 activate 钩子:Hook::fire('activate_plugin_'.$this->name)。每一个针对该名称注册的监听器都会执行 — 通常是插件自己注册的 Hook::on('activate_plugin_*', ...) 监听器,它对插件的迁移目录调用 artisan migrate。其他插件也可以在同一事件上注册额外监听器。
  2. 重新校验 composer.jsonself::validateMetaData($config) 校验插件必需的键(nameversionapp_version)存在且格式良好。缺键会在状态翻转之前抛错。
  3. 将数据库 status 设为 active 并保存行。
  4. 更新主文件:{ "status": "active", "error": null }error 重置会清掉之前任何启动失败,后续 autoload 扫描就会把插件视为健康。

激活在实践中是幂等的。对已激活插件再次执行 activate() 会重新触发钩子(所以执行 migrate 的监听器会再跑一次 — Laravel 的 migrations 表会对已运行的文件去重,所以第二次调用是空操作),重新校验,并写入相同状态。没有专门的「已经激活」分支。

状态 3 — Disable

Plugin.php:136 处的 $plugin->disable() 是四个方法中最简单的一个。它只做这些:

  1. 将数据库 status 设为 inactive
  2. 用新状态更新主文件,并清掉任何 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) 是完整拆除。按顺序四步:

  1. 触发 delete 钩子:Hook::fire('delete_plugin_'.$name, [$keepData])。脚手架的监听器会对插件迁移目录调用 artisan migrate:rollback$keepData 标志会被转发,监听器可以选择不回滚承载客户数据的表 — 实操模式见数据库与模型一页。
  2. 删除插件目录:$this->deletePluginDirectory() 递归移除 storage/app/plugins/{vendor}/{name}/。这一步之后,插件的 PHP 源代码已从磁盘消失。
  3. 删除数据库行。plugins 表中不再有指向此插件的引用。
  4. 移除主文件项: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:activateplugin:disableplugin: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 周期。