关于 craft-php-guidelines
面向 Craft CMS 5 插件和模块开发者的 PHP 编码规范指南,补充 Craft 官方规范中的项目约定。内容涵盖代码结构、类型与文档、日期处理、授权和工具链。
主要功能
- 01PHPDoc 与异常文档规范
- 02类结构及命名约定
- 03类型、比较与控制流规则
- 04日期和环境变量处理
- 05授权校验与工具链要求
适用场景
- 01编写 Craft CMS 插件和模块
- 02审查 PHP 代码及静态分析问题
- 03统一授权检查和日期处理方式
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ michtio/ craftcms-claude-skills --skill craft-php-guidelines安装后在 Claude Code 中输入/craft-php-guidelines确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/authorization-parity.mdclass-organization.mdnaming-conventions.mdphpdoc-standards.mdtemplates-and-patterns.mdtooling.md详细介绍
Craft CMS 5 PHP 指南
Craft CMS 5 插件和模块开发的完整 PHP 编码标准与惯例。本文在 Craft 官方编码指南的基础上,补充了项目特定的惯例。
核心原则: 所有内容都要编写 PHPDoc,包括类、方法和属性,不受类型提示限制。插件源文件中不使用 declare(strict_types=1)(遵循 Craft 核心惯例)。
配套技能 — 始终一起加载
craftcms— 架构模式、元素生命周期、控制器、事件、迁移。开发任何 Craft 插件或模块时都必须使用。ddev— 所有命令都通过 DDEV 运行。运行 ECS、PHPStan、脚手架生成器和测试时都必须使用。
文档
- 官方编码指南:https://craftcms.com/docs/5.x/extend/coding-guidelines.html
- 类参考:https://docs.craftcms.com/api/v5/
- 生成器参考:https://craftcms.com/docs/5.x/extend/generator.html
不确定某项惯例时,请使用 WebFetch 获取编码指南页面,以权威内容为准。
常见陷阱
addSelect()是beforePrepare()中的约定——多个扩展共同添加列时,它能安全地追加。$_instances不是 Craft 的约定——私有属性使用下划线前缀,但名称应有实际含义,例如$_items、$_sections。- 记录类与模型使用相同的类名(由命名空间区分)。同时导入两者时使用别名:
use ...\records\MyEntity as MyEntityRecord;。 - 队列任务没有「Job」后缀——使用
ResaveElements,而不是ResaveElementsJob。 - 插件源文件中不使用
declare(strict_types=1)。只有独立配置文件(如ecs.php)中才使用。 @author只写在类和方法上——绝不写在属性上。(Craft core 只在类级别添加@author;方法上也添加是本项目的惯例,并非 core 风格。)- 不要使用
string|null——使用?string(简短的可空类型写法)。 - 忘记调用
parent::defineRules()就会丢失所有继承的验证规则。 - 不要在
defineRules()中使用[$this, '_validateFoo']形式的可调用数组或内联闭包——Craft core 使用字符串方法名:[['attr'], 'validateAttr']。验证方法应为 public,且不带下划线——Yii 会按名称调用它。 - 在元素和查询中使用
DateTimeHelper,在服务中使用Carbon——不要在同一个类中混用。 - 使用
strtotime()或new DateTime()解析原始数据库日期时间——这些列是无时区的 UTC 字符串,而进程时区是system.timeZone,因此在非 UTC 安装中,结果会偏差完整的时区偏移量。请使用明确指定 UTC 时区的方式解析。参见下文「日期处理」。 - 不要遗漏
@throws调用链——也要记录所调用方法抛出的异常,而不只是自己抛出的异常。 - 不要使用魔术属性访问(
$plugin->settings、$app->view),而应使用显式 getter($plugin->getSettings()、$app->getView())——PHPStan 无法解析__get()调用,因此魔术访问虽然能在运行时通过,却会导致静态分析失败。始终对 Yii2 组件和 Craft 插件属性使用显式 getter。 - 不要直接在
Craft::$app上调用 Craft 专属方法(Craft::$app->getConfig())——PHPStan 无法解析这些方法,因为其静态类型是 Yii 的基础联合类型。请使用带类型的局部变量进行类型收窄:/** @var \craft\web\Application $app */ $app = Craft::$app;。不要使用@phpstan-ignore-line。 - 不要在多个类中将契约常量重复声明为
private const,再用注释要求「保持同步」——PHPStan 无法检测差异。应在所属服务中声明public const,并在其他位置统一引用OwnerService::CONSTANT_NAME。这尤其适用于权限句柄:诸如'my-plugin:manage-settings'的句柄是契约字符串,会在注册(EVENT_REGISTER_PERMISSIONS)、控制器权限检查(requirePermission())和导航检查(->can())中引用;直接使用字面值会悄然产生差异,而拼写错误会让管理员(他们拥有所有权限)仍能通过,却拒绝其他所有人。应在执行权限检查的控制器中将其声明为public const,例如SettingsController::PERMISSION_MANAGE_SETTINGS,并在所有位置引用该常量。(Craft core 在这里使用直接的字面值;使用常量是刻意采用的更严格项目规范。参见craftcmsskill 的permissions.md。) - 不要在 CP 控制器、控制台命令和 GraphQL 解析器中分别编写相同的授权检查——它们会逐渐偏离,而发生偏差的部分往往恰恰无人测试。应使用一个共享的权限门控方法,由所有入口调用,并为每个入口编写测试。控制台也不例外(没有权限检查的文档化 cron 路径属于未经身份验证的功能),GraphQL 架构作用域也不等同于插件的权限矩阵。参见
references/authorization-parity.md。 - 不要假设 Craft 会阻止用户审批或复核自己——Craft 没有这样的概念,而同侪权限属于另一条维度。应在共享门控中编写此限制,与角色检查正交,并提供明确的绕过权限。参见
references/authorization-parity.md。 - 不要在插件的
composer.json中提交../*路径仓库——依赖解析只会在作者的本地磁盘上正常工作。未发布的同级依赖应添加vcs条目;Packagist 依赖无需添加;插件的composer.lock应继续被 git 忽略。请先通过composer config --global repositories验证(预期为空),再运行不生成锁文件的composer update --dry-run进行验证。参见references/tooling.md(Composer Hygiene)。 - 不要将
Db::parseParam()用于字面值比较——开头或结尾的*会变成 SQLLIKE通配符,导致针对已存储模式的唯一性检查悄然变成前缀匹配。请使用原始的andWhere(['col' => $value])。参见craftcmsskill 的architecture.md。 - 不要在
init()中的getIsCpRequest()(或其他请求上下文)分支内注册EVENT_REGISTER_ELEMENT_TYPES/EVENT_REGISTER_FIELD_TYPES——组件类型注册必须在所有上下文(CP、控制台、站点)中运行,否则该类型会在控制台或队列请求中从getAllElementTypes()消失,且Gc::hardDeleteElements()会悄然停止清理已删除的行。应无条件注册;只有 CP 的渲染和路由相关逻辑(URL 规则、资源包、导航)可以受条件控制。参见craftcmsskill 的events.md→「Registration scope」。
参考文件
请阅读与你的任务相关的参考文件:
| 任务 | 阅读 |
|---|---|
编写 PHPDoc、@author、@since、@throws、@var、@param、类型引用 | references/phpdoc-standards.md |
| 类结构、章节标题、排序、枚举、控制流、注释、空白 | references/class-organization.md |
| 类、方法、属性、文件、服务、事件、迁移的命名 | references/naming-conventions.md |
| CP Twig 模板、表单宏、翻译、文件头、验证 | references/templates-and-patterns.md |
| ECS、PHPStan、脚手架命令、插件仓库的 Composer 规范、提交消息 | references/tooling.md |
| CP / console / GraphQL / queue 各层面的授权一致性、自引用保护、统一授权门控 | references/authorization-parity.md |
关键规则
- 所有内容都必须编写 PHPDoc:类、方法、属性均不例外。
@throws链:记录所有异常,包括被调用方法抛出但未捕获的异常。@author和@since放在类 / 方法文档块的底部,中间空一行。- 每个类都必须使用
// =========================================================================章节标题。(Craft 核心使用破折号分隔符// ----,并以// Statuses或// Events等功能 / 领域标签命名。=====分隔符和下面的可见性标签是为统一本项目的插件而采用的有意约定,并非核心风格。) - 插件源文件中不得使用
declare(strict_types=1)——Craft 的内部类型强制转换依赖 PHP 默认的弱类型模式。 - 私有方法 / 属性必须以下划线作为前缀:
_registerCpUrlRules()、$_items。 - 在
beforePrepare()中遵循addSelect()约定——支持扩展间叠加,并避免列冲突。 - 元素 / 查询中使用
DateTimeHelper,服务中使用Carbon——分离职责,避免在同一个类中混用日期 API。 - 始终使用
ddev craft make <type> --with-docblocks搭建脚手架,然后再进行自定义。 - 每次提交前,都必须通过
ddev composer check-cs和ddev composer phpstan。
PHP 标准
- 最低 PHP 版本为 8.2(Craft CMS 5 的要求)。
- 以 PSR-12 为基准,并采用 Craft 的修改项(尾随逗号、常量可见性)。
- 使用带有
SetList::CRAFT_CMS_4预设的craftcms/ecs(兼容 Craft 4 和 5)。 - 使用简短的可空类型写法:
?string,而非string|null。 - 始终指定
void返回类型。 - 所有属性都必须有类型。不得使用无类型的公共属性。
- 始终使用严格比较:
$foo === null、in_array($x, $y, true)。 - 使用强制转换,而非函数:
(int)$foo,而非intval($foo)。
章节标题顺序
// Traits
// Const Properties
// Static Properties
// Public Properties
// Protected Properties
// Private Properties
// Public Methods
// Protected Methods
// Private Methods
仅包含有内容的章节。分隔符后、第一项目前空一行。
控制流
- 将正常路径放在最后。 优先处理错误情况,并尽早返回。
- 避免使用
else——改用提前返回。 - 值映射和返回优先使用
match,而非switch。 官方指南是“单个if足以解决时,不要使用switch”;如果switch更清晰,也可以使用(在核心代码中也很常见)。 - 即使只有一条语句,也始终使用花括号。
- 将复合条件拆分为嵌套的
if语句,以提高可读性。 - 调用参数为 3 个或更多的方法时,使用命名参数。
日期处理
- 元素及元素查询:
craft\helpers\DateTimeHelper。 - 服务(日期运算):
Carbon\Carbon。 - 不要在同一个类中混用两者。
- 解析从数据库读取的值时,必须指定时区。 日期时间列保存的是不带时区的 UTC 字符串(
Db::prepareDateForDb()会以 UTC 格式化,且不包含偏移量),而 Craft 会将 PHP 进程时区设为system.timeZone——因此,在非 UTC 安装中,对原始列值使用strtotime()或不带参数的new DateTime(),会让每次比较都偏移完整的 UTC 时差;在 UTC 安装中则会悄然得到正确结果。使用DateTimeHelper::toDateTime($value)(默认将不带时区的输入视为 UTC),或Carbon::createFromFormat('Y-m-d H:i:s', $value, 'UTC')。参见craftcms技能的architecture.md(Record-to-Model Hydration Boundary → Those strings are naive UTC)。
环境变量访问
- 使用
craft\helpers\App::env('VAR'),绝不要使用getenv()——插件代码中如此,文档中的所有配置文件示例也如此。getenv()不是线程安全的,返回string|false,且会跳过仅存在于$_SERVER中的值;App::env()是 Craft 自己的惯例,会将'true'/'false'规范化为布尔值,也是核心文档采用的方式。配置示例应添加use craft\helpers\App;这一行。 - 当存储的值可能是
$VAR引用或别名时(用于解析可由环境变量设置的字段的设置模型 getter),使用App::parseEnv()。它只会解析完整字符串中的$VAR,或以斜杠分隔的…/$VAR/…片段。从 5.11.0 起,自由文本中的$(如"$58 million")会保持不变;在 5.0–5.10 中,未解析的标记会从字符串中移除(craftcms/cms#19522),因此在 5.11 之前的安装中,不要用它处理任意用户文案。 - 在将变量写入项目的
.env之前,使用Craft::$app->getConfig()->doesEnvVarExist('NAME')(5.11.0+)。它会搜索文件,因此回答的是“是否在.env中声明”,而不是“是否在环境中设置”;后者应使用App::env()。
数据库约定
- Yii2 联接条件中使用
[[column]]引用列。 - 在
beforePrepare()中使用addSelect()——这样可以安全地追加选择项。 - 在
addSelect()中包含postDate和expiryDate,并确保它们在元素表上有索引。 - 查询参数使用
Db::parseParam()。日期使用Db::parseDateParam()。但不要将其用于字面值比较——它会将开头或结尾的*视为LIKE通配符;需要精确比较存储值时,使用原始的andWhere(['col' => $value])。 - 外键应明确指定
CASCADE/SET NULL行为。
权限句柄
权限句柄使用 kebab-case(handle:manage-settings);绝不要使用 camelCase。 两部分均使用小写 kebab 格式——savepoint:manage-settings、multiplayer:take-over-field,而不是 savepoint:manageSettings。
Craft 会将权限名称转换为小写后存入 userpermissions.name,因此大小写在存储时会丢失:manageSettings 会变成 managesettings,而 manage-settings 会保留单词边界,在数据库、导出内容和调试输出中都易于辨认。
Craft 核心自身的权限采用 camelCase(accessCp、editUsers、viewPeerEntries)。插件自有句柄有意采用不同格式——不要为了与核心保持一致而将插件句柄“修正”为 camelCase,也不要改写核心句柄。
保存句柄的 PHP 常量仍使用 SCREAMING_SNAKE_CASE(PERMISSION_MANAGE_SETTINGS);只有字符串值使用 kebab 格式。完整机制见 craftcms 技能的 permissions.md。
命名速查
| 项目 | 约定 | 示例 |
|---|---|---|
| 服务(资源) | 复数 | Entries、Volumes、Users |
| 服务(实用工具) | 领域名词 | Auth、Search、Gc |
| 队列任务 | 动作动词,不加后缀 | ResaveElements、UpdateSearchIndex |
| 记录 | 与模型同名 | 使用命名空间区分 |
| 事件 | 三种模式 | SectionEvent、RegisterUrlRulesEvent、DefineHtmlEvent |
| 元素操作 | 动作动词,不加后缀 | Delete、Duplicate、SetStatus |
| 枚举 | PascalCase 格式的枚举项,由字符串/整数支持 | PropagationMethod、CmsEdition |
如需完整的命名参考(包括文件结构约定),请阅读 references/naming-conventions.md。
文案风格
面向用户的文案中,绝不使用 em dash(—)或 en dash(–),包括字段标签和说明、Craft::t() 字符串、CP 通知和闪现消息、控制台命令输出、插件/模块 README 及文档。请改用逗号、句号、冒号或括号;表示范围时写成“4 到 10”或使用普通 ASCII 连字符(“4-10”)。普通连字符可以使用。代码注释和 PHPDoc 不受此限制。完成前,请在 Craft::t() 字符串、模板和文档中搜索 — 和 –。(前端 Twig 文案:请参阅 craft-twig-guidelines 技能,其中对 |t 字符串和模板文本也有相同规定。)
控制台控制器文档块是终端帮助文本
Yii 会将控制台控制器的文档块原样渲染为面向操作者的帮助文本,不会解析内联标签:类文档块的第二行会成为 craft help 中的命令摘要,直到第一个 @tag 之前的正文会成为 help <command> 的内容,每个操作方法文档块的第一行会成为该操作的说明,而选项属性的 @var 文本会成为 --option 帮助。因此,在每个扩展 yii\console\Controller 的类中:类/属性文档块或操作方法首行不得使用 {@see}/{@link}/{@inheritdoc}(命令 ID 应写为纯文本),第二行不得使用 ========= 作为分节标题线(它会被显示为命令摘要),选项 @var 标签不得使用悬挂缩进的续行(它们会显示为参差不齐的缩进;请采用 craftcms/cms 的 ResaveController 那种顶格格式),摘要句必须放在第二行。使用 ddev craft help <plugin> 验证,craft help | grep '{@' 必须没有任何输出。
以下三点也受此规则约束,但仍容易出错:
- 摘要在物理上的文档块第 2 行,而不只是第一句话。
parseDocCommentSummary()(yii\console\Controller)只会返回trim($docLines[1]),不会做其他处理。如果第一句话换行到了第 3 行,打印出来的就只有第 2 行的片段(真实摘要曾被截断为“and writes”“whether every row”“prints the new”)。请将完整摘要句放在同一个物理行,即使超出通常的换行宽度也一样。 - 第 2 行的脚手架生成内容
* Class FooController会原样成为craft help摘要。 发布前,请将生成器样板替换为真正的摘要。 - 覆盖
getHelpSummary()/getHelp()后,文档块就会变成死代码。 类一旦覆盖这些方法,文档块和打印出的帮助文本就成了两个悄然不一致的真实来源;如果getHelp()原样返回摘要,命令就完全没有帮助正文。请优先只使用文档块;如果已有覆盖(或新增覆盖),请删除或调整其遮蔽的文案,并检查help <command>是否打印了实际正文。
验证清单
每次提交前:
ddev composer check-cs通过ddev composer phpstan通过- 测试全部通过
- 所有新增/修改代码的 PHPDocs 完整
- 已验证
@throws链 - 分节标题存在且正确
- 导入项按字母顺序平铺排列(由 ECS 强制执行,不是“PHP 全局项优先”)
- 面向用户的文案中没有 em dash(—)或 en dash(–)(
Craft::t()字符串、标签、CP 通知、README、文档)