墨林MOLININK

插件开发指南

插件开发指南

最近更新:2026-11-07

插件负责「功能」:改内容、发邮件、接支付、加后台页 —— 凡是"长相"以外的事都归它。内核只当插座,插件是插头;插头规格(契约)定死了,谁做都一样。

本指南面向插件作者,从最小可跑的例子讲到打包上传。文中字段名、钩子名、接口名以当前内核实际实现为准,出处都在文末。


0 · 四条底线

  1. 业务进插件,不进内核也不进主题。 内核做结构,主题做显示,剩下的都是插件的活。
  2. 要用什么能力,先在清单里写明。 装插件的时候用户看得到你要动什么;不写就调,内核会拦。
  3. 挂钩子先写进 hooks 声明。 漏声明不报错,但回调永远不会被触发 —— 这是最坑的一类"插件没生效"。
  4. 目录名 = 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
contract1.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 / afterfilter正文输出前/后改 HTML
content/save/before / afterfilter / action文章落库前改数据、落库后做事
content/fetch/before / afterfilter改查询条件、改查询结果
post/publish/before / afteraction发布前后做事(推送、通知)
comment/submit/before / afterfilter / action评论提交前后(反垃圾、通知)
user/login/before / after / failedfilter / action登录链(强制验证码走 before,可抛异常中止)
user/register/before / afterfilter / action注册链(邀请码/验证码走 before)
media/upload/before / replace / afterfilter / action上传改白名单、换图、后处理
theme/head/render / theme/footer/renderaction往页头/页尾倒 HTML(统计代码、第三方脚本)
admin/menu/collectfilter后台侧栏加菜单项
admin/dashboard/collectfilter后台首页加卡片
admin/editor/collect / renderfilter / action编辑器加按钮/面板(0.2.7 起)
system/initaction内核启动完成,做初始化
system/cron/collectfilter登记定时任务
site/sitemap/collect / site/feed/collect / site/seo/replacefilter站点地图/订阅/SEO 输出
plugin/activate/after / deactivate/afteraction自己被启用/停用时做事

> 现在有两个名字写了不会响: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 互传。