InnoCMS 插件系统详解:从 Hook 到完整工程化封装
Hook 是 InnoCMS 插件的底层抽象(参见 《Hook 系统详解》),但「插件」本身是个更大的概念:一个完整的工程化封装,包含元信息、入口类、路由、视图、迁移、设置、生命周期管理。这篇文章把"插件"作为工程实体的全部维度讲清楚,给想自己写插件或评估插件质量的人一张地图。
插件加载的五个阶段
当请求到达 InnoCMS 时,PluginManager 做的事情:
- 扫描:读取
plugins/目录下所有子目录,识别潜在插件 - 解析 config.json:每个子目录必须有
config.json,含 code/name/version/author。格式不对的目录被跳过 - 激活检查:查数据库
plugins表,看这个 code 是否被启用。未启用的插件不进入下一步 - 实例化 Boot:require
Boot.php(PSR-4 命名空间Plugin\<Code>\Boot),new 一个实例 - 调用 init():执行
Boot::init(),里面是插件所有 hook 注册、路由加载、Blade 注入逻辑
整个加载过程在每个请求都跑一遍(生产环境开 opcache,PHP 类实例化成本可忽略)。所以插件代码必须是无状态的,不能依赖全局副作用。
插件的标准目录结构
plugins/YourPlugin/
├── config.json # 必填:插件身份证
├── Boot.php # 必填:入口类
├── Controllers/ # 后台/前台控制器
├── Models/ # 插件专属模型
├── Routes/
│ ├── panel.php # 后台路由
│ └── front.php # 前台路由
├── Views/ # Blade 模板
├── Lang/ # 翻译
├── Migrations/ # 数据库迁移
├── Static/ # 静态资源(JS/CSS)
└── Settings/ # 设置字段定义(自动生成设置页)
config.json:插件元信息
{
"code": "ArticleShare",
"name": "文章分享按钮",
"description": "在文章详情页底部添加社交分享按钮",
"version": "1.2.0",
"author": "InnoCMS Team",
"requires": {
"innocms": "^1.0"
}
}
注意几个字段:
code必须跟目录名完全一致(推荐 PascalCase)version用语义化版本号,升级时用得到requires.innocms声明兼容的 InnoCMS 版本范围,避免在新版本上跑挂
Boot.php:插件的入口
namespace Plugin\ArticleShare;
use Plugin\ArticleShare\Models\ShareLog;
class Boot
{
public function init(): void
{
// 1. 注册后台侧栏菜单
listen_hook_filter('component.sidebar.plugin.routes', function ($data) {
$data[] = [
'route' => 'article_share.index',
'title' => '分享统计',
'icon' => 'bi-share',
];
return $data;
});
// 2. 注入文章页的分享按钮
listen_blade_insert('article.share', function () {
return view('ArticleShare::buttons');
});
// 3. 过滤文章数据,附加 share_url
listen_hook_filter('article.show.data', function ($data) {
$data['share_url'] = urlencode($data['article']->url);
return $data;
});
// 4. 监听分享点击事件
listen_hook_action('article.share.clicked', function ($articleId) {
ShareLog::create([
'article_id' => $articleId,
'ip' => request()->ip(),
]);
});
}
}
四种 hook 各司其职:
- Filter
component.sidebar.plugin.routes—— 给后台加菜单项 - Blade insert
article.share—— 在主题模板的 hook 点显示按钮 - Filter
article.show.data—— 给渲染数据附加 share_url - Action
article.share.clicked—— 记录分享点击的副作用
路由:插件自己的 URL
Routes/panel.php 自动挂载到 /panel/plugins/<code>/ 前缀下:
// plugins/ArticleShare/Routes/panel.php
use Illuminate\Support\Facades\Route;
use Plugin\ArticleShare\Controllers\ShareController;
Route::get('/', [ShareController::class, 'index'])
->name('article_share.index');
Route::delete('/{log}', [ShareController::class, 'destroy'])
->name('article_share.destroy');
访问 /panel/plugins/ArticleShare/ 就是 ShareController::index。front.php 同理挂载到 /<locale>/plugins/<code>/。
视图命名空间
插件的 Blade 视图用 PluginCode::view-name 命名空间引用:
// 在某个 closure 里
return view('ArticleShare::buttons', [
'url' => $shareUrl,
'title' => $articleTitle,
]);
对应文件 plugins/ArticleShare/Views/buttons.blade.php。命名空间 = config.json 的 code。
设置页:自动生成表单
插件需要可配置项(API Key、显示位置、自定义文案)时,不用自己写设置页 —— 把字段声明放进 Settings/fields.php,系统会自动生成完整的设置表单 + 数据持久化:
// plugins/ArticleShare/Settings/fields.php
return [
[
'name' => 'enabled_platforms',
'label' => '启用的平台',
'type' => 'checkbox',
'options' => [
'wechat' => '微信',
'weibo' => '微博',
'twitter' => 'Twitter',
'facebook' => 'Facebook',
],
'default' => ['wechat', 'weibo'],
],
[
'name' => 'button_style',
'label' => '按钮样式',
'type' => 'select',
'options' => ['square' => '方形', 'round' => '圆形'],
'default' => 'round',
],
];
代码里用 plugin_setting('ArticleShare', 'enabled_platforms') 读值。后台「插件管理 → ArticleShare → 设置」自动展示这个表单。
迁移:插件需要自己的表时
// plugins/ArticleShare/Migrations/2024_06_19_000001_create_share_logs_table.php
return new class extends Migration {
public function up(): void {
Schema::create('plugin_article_share_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('article_id')->constrained()->cascadeOnDelete();
$table->string('platform', 20);
$table->string('ip', 45)->nullable();
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('plugin_article_share_logs');
}
};
启用插件时自动 migrate,卸载时自动 migrate:rollback。表名约定 plugin_<code_snake>_<entity>,避免跟内核表冲突。
生命周期:启用、禁用、卸载
插件有三种状态:
- 已安装未启用:目录在
plugins/,但plugins表active=0。Boot::init()不执行 - 启用:
active=1,Boot::init()执行,迁移已跑 - 已卸载:目录被删除,
plugins表记录被清,migrate:rollback跑过,所有数据表清理
插件可以监听自己的生命周期事件做清理(删缓存、清日志、通知外部服务):
listen_hook_action('plugin.installed.ArticleShare', function () {
Cache::forget("article_share:*");
});
listen_hook_action('plugin.uninstalled.ArticleShare', function () {
// 卸载前清理外部资源
});
一个完整的插件案例:PartnerLink
看一个真实生产插件 plugins/PartnerLink(友情链接管理)的代码量分布:
config.json—— 8 行Boot.php—— 60 行(4 个 hook 注册)Controllers/PartnerLinkController.php—— 150 行(CRUD)Models/PartnerLink.php—— 25 行Migrations/—— 1 个文件,30 行Views/—— 5 个 Blade 模板,共 280 行Routes/panel.php—— 8 行Lang/zh-cn/partner_link.php—— 12 行
总计约 600 行代码就完成了一个完整的友情链接管理插件 —— 后台 CRUD、前台展示、多语言、自动迁移、卸载清理。这就是 InnoCMS 插件系统的工程化收益。
插件开发的避坑清单
- 命名空间必须正确:
Plugin\<Code>\...,跟目录名 / config.json code 一致。不一致 PSR-4 加载不到 - Boot::init() 必须可重入:每次请求都会执行,不要在里面做"只该执行一次"的副作用
- 不要直接读 other 插件目录:用 PluginManager API,避免依赖加载顺序
- 迁移文件名必须含时间戳:跟 Laravel 主迁移命名一致,否则 migration order 错乱
- 视图命名空间用 code:
ArticleShare::buttons,不是articlesshare::buttons(大小写敏感) - 设置字段 default 必须给:插件首次启用还没保存设置时,
plugin_setting会返回 default 值
掌握这套结构,写一个完整的 InnoCMS 插件通常 1-3 天 —— 取决于业务复杂度。后续文章会拆常见插件模式(支付集成、外部 API 同步、SEO 自动化)的具体实现。