插件开发指南
最近更新:2026-11-07
插件负责「功能」:改内容、发邮件、接支付、加后台页 —— 凡是"长相"以外的事都归它。内核只当插座,插件是插头;插头规格(契约)定死了,谁做都一样。
本指南面向插件作者,从最小可跑的例子讲到打包上传。文中字段名、钩子名、接口名以当前内核实际实现为准,出处都在文末。
0 · 四条底线
- 业务进插件,不进内核也不进主题。 内核做结构,主题做显示,剩下的都是插件的活。
- 要用什么能力,先在清单里写明。 装插件的时候用户看得到你要动什么;不写就调,内核会拦。
- 挂钩子先写进
hooks声明。 漏声明不报错,但回调永远不会被触发 —— 这是最坑的一类"插件没生效"。 - 目录名 =
name字段 =Molin\Plugins\{Name}的名字。 三处对不上,后面全是难查的坑。
1 · 插件目录结构
plugins/hello-note/
├── plugin.json 清单(必填,全插件唯一必填文件)
├── bootstrap.php 入口(名字随你定,清单里用 main 指向它)
├── src/ 业务代码(PSR-4 自动加载)
│ └── SettingsPage.php
├── views/ 后台页模板(可选,Twig)
├── languages/ 语言包(可选,{locale}.json)
├── migrations/ 建表迁移(可选,要用自建表才需要)
└── uninstall.php 卸载脚本(可选)
三条硬约束:
- 目录名必须等于
plugin.json的name,打包脚本与上传安装都按这个对账。 src/的命名空间固定为Molin\Plugins\{StudlyName}\,hello-note→Molin\Plugins\HelloNote\。内核按这条规则自动注册 PSR-4,你不用写 composer 配置。- 插件不得读写
core/、themes/。 要放数据一律走storage/plugins/{name}/。
2 · plugin.json 清单
2.1 最小可跑的清单
{
"name": "hello-note",
"title": "示例:文末签名",
"version": "1.0.0",
"contract": "1.x",
"author": "你的名字",
"main": "bootstrap.php",
"hooks": ["content/render/after"]
}
六个必填字段一个都不能少,缺了直接拒装:
| 字段 | 规则 |
|---|---|
name | 小写字母开头,只含小写字母/数字/连字符,2–50 字,等于目录名 |
title | 人类可读名,可中文,1–80 字 |
version | 严格三段版本号(1.0.0),不带 v |
contract | 1.x;主版本号与内核契约不符时拒装 |
author | 字符串或 {name, email, url} 对象 |
main | 入口文件相对路径,文件必须真实存在 |
hooks 严格说是选填,但实际上当必填用:不声明,内核就认为你只关心 system/init,启动时全量加载(见 §3.3)。
2.2 常用选填字段
| 字段 | 用途 |
|---|---|
description | 一句话简介,后台列表显示 |
license | 如 MIT,缺省按专有处理 |
requires | 环境要求:{"php": ">=8.2", "ext": ["openssl"]},不满足拒绝启用 |
capabilities | 能力声明,见 §4 |
admin | 后台菜单与页面声明,见 §5 |
routes | 自定义路由,见 §5 |
tables | 建表迁移声明,见 §6 |
uninstall | 卸载脚本相对路径,删插件时先跑它 |
3 · 钩子:插件的核心动作
3.1 两种钩子
// filter:有入值,必须原样返回同类型的值 —— 漏 return 就是把值改成了 null
add_filter('content/render/after', function (string $html): string {
return $html . '<p>追加一行</p>';
});
// action:无返回值,只做事
add_action('post/publish/after', function (Post $post, User $user): void {
// 推通知、写日志……
}, 10, 2);
写法两件套:add_filter(钩子名, 回调, 优先级, 接收参数个数) 与 add_action(...)。优先级默认 10,内核执行顺序 = 优先级升序 → 同级按依赖拓扑序 → 再同级按字母序,结果是确定的,不靠运气。
3.2 常用钩子速查
完整清单见《11-钩子规范》(冻结 59 个,只增不改),常用的一批:
| 钩子 | 类型 | 干什么用 |
|---|---|---|
content/render/before / after | filter | 正文输出前/后改 HTML |
content/save/before / after | filter / action | 文章落库前改数据、落库后做事 |
content/fetch/before / after | filter | 改查询条件、改查询结果 |
post/publish/before / after | action | 发布前后做事(推送、通知) |
comment/submit/before / after | filter / action | 评论提交前后(反垃圾、通知) |
user/login/before / after / failed | filter / action | 登录链(强制验证码走 before,可抛异常中止) |
user/register/before / after | filter / action | 注册链(邀请码/验证码走 before) |
media/upload/before / replace / after | filter / action | 上传改白名单、换图、后处理 |
theme/head/render / theme/footer/render | action | 往页头/页尾倒 HTML(统计代码、第三方脚本) |
admin/menu/collect | filter | 后台侧栏加菜单项 |
admin/dashboard/collect | filter | 后台首页加卡片 |
admin/editor/collect / render | filter / action | 编辑器加按钮/面板(0.2.7 起) |
system/init | action | 内核启动完成,做初始化 |
system/cron/collect | filter | 登记定时任务 |
site/sitemap/collect / site/feed/collect / site/seo/replace | filter | 站点地图/订阅/SEO 输出 |
plugin/activate/after / deactivate/after | action | 自己被启用/停用时做事 |
> 现在有两个名字写了不会响:theme/sidebar/collect(挂件层已删)与 media/render/after(渲染路径无触发点)。名字留在冻结清单里,但别指望它执行。
3.3 懒加载与 hooks 声明(必读)
内核只在你声明的钩子被触发时才加载你的代码。声明和注册必须一致:
"hooks": ["content/render/after", "post/publish/after"]
// bootstrap.php 里注册的就是这两个 —— 一致,OK
add_filter('content/render/after', fn (string $html): string => /* ... */);
add_action('post/publish/after', fn (Post $p, User $u): void => /* ... */, 10, 2);
漏声明的后果链:注册成功 → 索引里没有你 → 运行时不加载你 → 回调永远不执行 → 网站不报错,功能静默失效。
内核的两道防线:
- 开发模式(
APP_DEBUG=true):system/init结束后比对"实际注册 vs 声明",不一致直接抛异常,当场揪出; - 生产模式:记 WARNING 日志,不崩站。
自查习惯:写完一个 add_filter/add_action,马上看一眼清单里有没有这个钩子名。大量挂钩子不想逐个写,直接 "hooks": ["*"](全量加载,牺牲一点性能换省心,合法选项)。
3.4 插件间事件
插件自己发事件就是发一个 action,别的插件挂它:
do_action('shop/payment/after', $order, $user);
名字仍守三段式,事件的订阅方也要把它写进自己的 hooks 声明,否则懒加载拉不起来。
4 · 能力声明(capabilities)
插件默认无权。 要用什么,先写进清单:
"capabilities": ["mail.send", "log.write", "setting.read", "setting.write", "db.direct", "file.write", "admin.menu", "admin.page"]
- 词表是封闭的,32 项,完整清单见《62-插件能力清单》。常用:
content.read/write/delete、media.upload、mail.send、http.outbound(外呼)、cron.register、admin.menu/admin.page/admin.asset。 - 高危五项:
db.direct(直接查库)、file.write(写文件)、user.role.change、auth.hook、crypto.keys。装的时候用户界面单独警示,上架审核更严。 - 运行时真拦的目前是五项:
db.direct、file.write、setting.write、http.outbound、user.read_email—— 走内核包装层(Database::pdo()、Files::write()、Options::set()、Http::get()等)就会被拦,未声明抛CapabilityDeniedException。直调 PHP 原生函数拦不住,那是制度约束不是沙箱 —— 但上架审核会查,别绕。 - 要自定义能力名,只能用
你的插件名.动作的形式,并可用register_capability('hello-note.manage', '管理文末签名')登记显示名。
5 · 后台页与自定义路由
5.1 后台菜单与页面(纯展示)
"admin": {
"menu": [
{
"id": "hello-note",
"title": "文末签名",
"children": [
{
"route": "hello-note",
"title": "文末签名",
"template": "index.twig",
"controller": "Molin\\Plugins\\HelloNote\\SettingsPage"
}
]
}
]
}
- 前提:
capabilities里同时声明admin.menu与admin.page,缺一不认。 route:后台页地址(小写字母数字加-,段间/),不能与内核页重名。template:插件views/下的 Twig 文件,模板用{% extends "layout.twig" %}套后台外壳。- 入口在插件列表行内的「管理」按钮;停用即 404。
- 插件页不能自带 CSS/JS —— 皮肤复用后台已有的类(
.page-settings、.page-health等),照抄官方示例插件。
5.2 写动作(表单保存)
controller 指一个类,四条硬要求:合法类名、落在自己命名空间下、实现 Molin\Plugin\AdminPageController、能无参构造。
namespace Molin\Plugins\HelloNote;
use Molin\Http\Response;
use Molin\Plugin\AdminPageContext;
use Molin\Plugin\AdminPageController;
final class SettingsPage implements AdminPageController
{
public function handle(AdminPageContext $context): array|Response
{
if ($context->isPost()) {
$input = trim($context->request->input('signature'));
$context->settings->set('signature', $input);
return Response::redirect($context->url . '?done=saved'); // PRG
}
return [
'signature' => $context->settings->get('signature', '默认值'),
'notice' => $context->request->input('done') === 'saved' ? '已保存。' : '',
];
}
}
- 返回数组 → 并进模板上下文渲染本页;返回
Response→ 原样返回(重定向、JSON 都走这条)。 - 三道闸内核先过,你不用管:登录会话、页面能力、POST 的 CSRF 令牌。表单里放内核注入的
csrf_field/csrf_token即可。 - 上下文给你:
$request/$user/$options(站点配置,只读)/$db(要声明db.direct)/$settings(本插件配置)/$plugin/$route/$url/isPost()。
5.3 插件配置存取
写动作存值走 $context->settings:
$context->settings->set('signature', $input); // 只收 string
$value = $context->settings->get('signature', '默认值'); // string
- 落在 options 表,键名自动带前缀
plugin.{插件名}.,别的插件盖不了你,你也碰不到站点设置。 - 要存数组自己
json_encode,回来什么形状进去什么形状。 - 钩子/前台等别处只读用全局助手:
plugin_setting('hello-note', 'signature', '默认值')。 - 值进 HTML 时是用户输入,转义自己负责(模板自动转义兜不住你 PHP 里拼的字符串)。
5.4 自定义路由
"routes": [
{ "method": "GET", "path": "/shop/cart", "handler": "CartController@index", "auth": true },
{ "method": "POST", "path": "/api/pay/callback", "handler": "PayController@callback", "guard": "none" }
]
method只认 GET/POST/PUT/DELETE;path以/开头,不许..。handler短类名自动补你的命名空间;写全名必须落在自己命名空间下,指到内核或别的插件按无此路由处理。auth缺省true(要登录);支付回调这类带不了会话的写guard: "none",但只允许/api/开头的路径。- 与内核路径冲突 → 拒绝启用插件。控制器接不上不连坐,该条按 404,日志记一条。
6 · 建表(需要自建数据表时)
"tables": [
{ "name": "shop_orders", "migration": "migrations/001_create_orders.php" }
]
迁移文件形状与内核同一套:
namespace Molin\Plugins\Shop\Migrations;
use Molin\Migrations\AbstractMigration;
final class CreateOrders001 extends AbstractMigration
{
public function up(): void { $this->execute('CREATE TABLE …'); }
public function down(): void { /* 回滚,必须有,缺了拒绝启用 */ }
}
- 文件名
序号_小写下划线.php,类名由文件名推出(001_create_orders.php→CreateOrders001)。 - 表名用
$this->prefix()拼前缀,与内核一套。 - 启用时按序跑没跑过的
up;up中途失败自动补偿回退并拒绝启用,不留半截表。停用不动表;删除时先跑uninstall.php再倒序down。
7 · 语言包
插件页文案照常走 __(),域写插件名:
__('文末签名已保存。', 'hello-note')
{{ __('签名', 'hello-note') }}
包放 plugins/{name}/languages/{locale}.json(键 = 源文案,值 = 译文)。源语言是简体中文,不提供 zh_CN.json。域留空等于把文案挤进内核词表,别这么干。
8 · 打包与上传
8.1 打包
在插件源码目录(仓库的 插件\ 层,不是站点的 plugins\)双击 打包插件.cmd,或:
php pack-plugin.php hello-note 打一个
php pack-plugin.php 本目录全部
php pack-plugin.php --to-dev 打完把源码铺进开发树 plugins/ 供本地实测
产物落 _dist\{name}-{version}-{时间}.zip。内容平铺在 zip 根(plugin.json 在根上,不裹外层目录),与后台「上传插件」吃的形状一致。打好的包当场交给内核 ZipInstaller 跑一遍真校验,过不了不会出货。
8.2 上传安装
后台 → 插件 → 上传插件,选 zip。安装时逐项校验:JSON 合法、必填齐全、name 等于 zip 内清单的 name、main 存在、requires 满足、契约兼容、依赖满足、迁移有 down()、路径穿越/炸弹闸(上传 ≤ 32 MB、解压总量 ≤ 256 MB)。任一不过整体拒收,不留半成品。
升级 = 上传新版包:同名插件、版本号必须严格更高(不许降级、不许同版本重装),旧目录先退役再原子替换,留 3 个备份。
8.3 发布前自检
[ ] 目录名 = name,小写连字符
[ ] 六个必填字段齐全,version 三段号
[ ] hooks 声明与实际注册一一对应(开发模式跑一遍会自动查)
[ ] capabilities 够用且如实(高危项三思)
[ ] 要 db 用 $context->db,别自己 new PDO
[ ] 用户输入进 HTML 前自己转义
[ ] 表单带 csrf_field / csrf_token
[ ] 文案都带自己的域
[ ] 所有文件带版权头(见 §9)
9 · 版权信息规范
每个文件都带,位置固定、简短:
/**
* 插件名
* @author 你的名字
* @link https://你的站点
*/
declare(strict_types=1);
Twig / JS / CSS:{# 插件名 · 作者 你的名字 · https://你的站点 #} 一行注释即可。
10 · 现成范例
仓库里可直接抄的三个:
hello-note(最小样板):清单 + 一个 filter + 一张后台表单,前文代码全部出自它。mail-smtp(实用级):SMTP 发信、密钥写storage/.secrets/、挂两个登录/评论钩子、后台页改配置。molin-social(业务域示范):整块社交/通知功能住在插件里,内核只留接口 —— 想看"怎么把一大块业务做成插件",读它。
开发树 plugins\ 是安装态(站点装了什么在那儿),源码别直接写在那里 —— 在 插件\ 层改,打完包传上去,或 --to-dev 铺过去实测。
11 · 现在还没有的(别等)
- 入口无生命周期接口:没有
activate()/deactivate()方法可实现,靠plugin/activate/after等钩子做事。 - 插件后台页不能注入自己的 CSS/JS。
settings清单字段(像主题那样声明配置项)尚无消费方 —— 配置存取走 §5.3 那套。- 市场尚未开张,分发靠 zip 互传。