craft-php-guidelines — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited craft-php-guidelines (Agent Skill) and scored it 92/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 2 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 2 flagged
The text {match} tells the agent to skip the normal "ask the user first" gate. Used adversarially it removes the human-in-the-loop check before destructive or sensitive actions, turning a normally-gated agent into a fire-and-forget executor.
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Complete PHP coding standards and conventions for Craft CMS 5 plugin and module development. These extend Craft's official coding guidelines with project-specific conventions.
Core principles: PHPDocs on everything — classes, methods, and properties — regardless of type hints. No declare(strict_types=1) in plugin source files (matching Craft core convention).
When unsure about a convention, WebFetch the coding guidelines page for the authoritative answer.
addSelect() is the convention in beforePrepare() — safely additive when multiple extensions contribute columns.$_instances is not a Craft convention — private properties use underscore prefix but meaningful names like $_items, $_sections.use ...\records\MyEntity as MyEntityRecord;.ResaveElements, not ResaveElementsJob.declare(strict_types=1) is NOT used in plugin source files. Only in standalone config files like ecs.php.@author goes on classes and methods only — never on properties. (Craft core puts @author at the class level only; placing it on methods too is this project's house convention, not core style.)string|null — use ?string (short nullable notation).parent::defineRules() and you lose all inherited validation.[$this, '_validateFoo'] callable arrays or inline closures in defineRules() — Craft core uses string method names: [['attr'], 'validateAttr']. The validator method is public, no underscore — Yii invokes it by name.DateTimeHelper in elements/queries, Carbon in services — never mix in the same class.strtotime() or new DateTime() — those columns are naive UTC strings and the process timezone is system.timeZone, so the result is off by the full offset on any non-UTC install. Parse with an explicit UTC zone. See Date Handling below.@throws chains — document exceptions from called methods too, not just your own throws.$plugin->settings, $app->view) instead of explicit getters ($plugin->getSettings(), $app->getView()) — PHPStan can't resolve __get() calls, so magic access passes at runtime but fails static analysis. Always use explicit getters for Yii2 components and Craft plugin properties.Craft::$app (Craft::$app->getConfig()) — PHPStan can't resolve them because the static type is Yii's base union. Narrow with a typed local: /** @var \craft\web\Application $app */ $app = Craft::$app;. Don't use @phpstan-ignore-line.private const across multiple classes with "keep in lockstep" comments — PHPStan can't detect drift. Declare public const on the owning service, reference as OwnerService::CONSTANT_NAME everywhere else. This applies specifically to permission handles: a handle like 'my-plugin:manage-settings' is a contract string referenced from registration (EVENT_REGISTER_PERMISSIONS), the controller gate (requirePermission()), and the nav check (->can()); a bare literal drifts silently and a typo passes for admins (who hold every permission) while denying everyone else. Declare it as a public const on the controller that enforces it — SettingsController::PERMISSION_MANAGE_SETTINGS — and reference the const everywhere. (Craft core uses bare literals here; the const is a deliberately stricter house rule. See the craftcms skill's permissions.md.)references/authorization-parity.md.references/authorization-parity.md.../* path repositories in a plugin's composer.json — resolution works only on the author's disk. Unpublished sibling deps get a vcs entry; Packagist deps need nothing; composer.lock stays gitignored for plugins. Prove it with composer config --global repositories (expect empty) then a no-lock composer update --dry-run. See references/tooling.md (Composer Hygiene).Db::parseParam() for a literal comparison — a leading or trailing * becomes a SQL LIKE wildcard, so a uniqueness check on a stored pattern silently becomes a prefix match. Use a raw andWhere(['col' => $value]). See the craftcms skill's architecture.md.EVENT_REGISTER_ELEMENT_TYPES / EVENT_REGISTER_FIELD_TYPES inside a getIsCpRequest() (or other request-context) branch in init() — component-type registration must run in every context (CP, console, site) or the type disappears from getAllElementTypes() in console/queue requests, and Gc::hardDeleteElements() silently stops purging its trashed rows. Register unconditionally; only CP-rendering/routing (URL rules, asset bundles, nav) may be gated. See the craftcms skill's events.md → "Registration scope".Read the relevant reference file(s) for your task:
| Task | Read |
|---|---|
Writing PHPDocs, @author, @since, @throws, @var, @param, type references | references/phpdoc-standards.md |
| Class structure, section headers, ordering, enums, control flow, comments, whitespace | references/class-organization.md |
| Naming classes, methods, properties, files, services, events, migrations | references/naming-conventions.md |
| CP Twig templates, form macros, translations, file headers, validation | references/templates-and-patterns.md |
| ECS, PHPStan, scaffolding commands, composer hygiene for plugin repos, commit messages | references/tooling.md |
| Authorization parity across CP / console / GraphQL / queue surfaces, self-referential guards, one shared gate | references/authorization-parity.md |
@throws chains: document every exception including uncaught from called methods.@author and @since at the bottom of class/method docblocks, after a blank line.// ========================================================================= on every class. (Craft core itself uses dash separators — // ---- — with functional/domain labels like // Statuses or // Events. The ===== separators and visibility labels below are a deliberate house convention for consistency across this project's plugins, not core style.)declare(strict_types=1) is NOT used in plugin source files — Craft's internal type coercion depends on PHP's default weak typing mode._registerCpUrlRules(), $_items.addSelect() convention in beforePrepare() — additive across extensions, prevents column conflicts.DateTimeHelper in elements/queries, Carbon in services — separate concerns prevent mixing date APIs in the same class.ddev craft make <type> --with-docblocks, then customize.ddev composer check-cs and ddev composer phpstan must pass before every commit.craftcms/ecs with SetList::CRAFT_CMS_4 preset (covers both Craft 4 and 5).?string not string|null.void return types.$foo === null, in_array($x, $y, true).(int)$foo not intval($foo).// Traits
// Const Properties
// Static Properties
// Public Properties
// Protected Properties
// Private Properties
// Public Methods
// Protected Methods
// Private MethodsOnly include sections that have content. Blank line after the separator, before the first item.
switch when a single if suffices"; switch remains acceptable where it reads more clearly (and is common in core).if statements for readability.craft\helpers\DateTimeHelper.Carbon\Carbon.Db::prepareDateForDb() formats in UTC without an offset), while Craft sets the PHP process timezone to system.timeZone — so strtotime() or bare new DateTime() on a raw column value shifts every comparison by the full UTC offset on a non-UTC install, and is silently correct on a UTC one. Use DateTimeHelper::toDateTime($value) (naive input is assumed UTC by default) or Carbon::createFromFormat('Y-m-d H:i:s', $value, 'UTC'). See the craftcms skill's architecture.md (Record-to-Model Hydration Boundary → Those strings are naive UTC).getenv() is not thread-safe, returns string|false, and skips values only present in $_SERVER; App::env() is Craft's own convention, normalizes 'true'/'false' to booleans, and is what core docs show. A config example gets the use craft\helpers\App; line.App::parseEnv() when the stored value may be a $VAR reference or an alias (settings-model getters resolving env-able fields). It resolves a whole-string $VAR or a slash-delimited …/$VAR/… segment only. Since 5.11.0 a $ inside free text ("$58 million") is left untouched; 5.0–5.10 stripped the unresolved token out of the string (craftcms/cms#19522), so don't route arbitrary user copy through it on a pre-5.11 install.Craft::$app->getConfig()->doesEnvVarExist('NAME') (5.11.0+) before offering to write a variable to the project's .env. It greps the file, so it answers "declared in .env", not "set in the environment"; use App::env() for the latter.[[column]] quoting in Yii2 join conditions.addSelect() in beforePrepare() — safely additive.postDate and expiryDate in addSelect() and indexed on element tables.Db::parseParam() for query parameters. Db::parseDateParam() for dates. But not for literal comparison — it treats a leading/trailing * as a LIKE wildcard; use a raw andWhere(['col' => $value]) when comparing stored values exactly.CASCADE / SET NULL behavior.Permission handles are kebab-case (`handle:manage-settings`); never camelCase. Both halves are lowercase kebab — savepoint:manage-settings, multiplayer:take-over-field, not savepoint:manageSettings.
Craft lowercases permission names into userpermissions.name, so case is discarded on the way to storage: manageSettings collapses to managesettings, while manage-settings keeps its word boundaries and stays readable in the database, in exports, and in debug output.
Craft core's own permissions are camelCase (accessCp, editUsers, viewPeerEntries). This is a deliberate divergence for plugin-owned handles — don't "correct" plugin handles back to camelCase for consistency with core, and don't rewrite core's handles.
The PHP constant holding the handle stays SCREAMING_SNAKE_CASE (PERMISSION_MANAGE_SETTINGS); only the string value is kebab. Full mechanics in the craftcms skill's permissions.md.
| Thing | Convention | Example |
|---|---|---|
| Services (resource) | Plural | Entries, Volumes, Users |
| Services (utility) | Domain noun | Auth, Search, Gc |
| Queue jobs | Action verb, no suffix | ResaveElements, UpdateSearchIndex |
| Records | Same name as model | Namespace distinguishes |
| Events | Three patterns | SectionEvent, RegisterUrlRulesEvent, DefineHtmlEvent |
| Element actions | Action verb, no suffix | Delete, Duplicate, SetStatus |
| Enums | PascalCase cases, string/int backed | PropagationMethod, CmsEdition |
For the complete naming reference including file structure conventions, read references/naming-conventions.md.
Never use em-dashes (—) or en-dashes (–) in user-facing copy: field labels and instructions, Craft::t() strings, CP notices and flash messages, console command output, plugin/module README, and docs. Use commas, periods, colons, or parentheses instead; for ranges write "4 to 10" or a plain ASCII hyphen ("4-10"). Plain hyphens are fine. Code comments and PHPDoc are exempt. Grep for — and – in your Craft::t() strings, templates, and docs before finishing. (Front-end Twig copy: see the craft-twig-guidelines skill, which carries the same rule for |t strings and template text.)
Yii renders console controller docblocks VERBATIM as operator-facing help, with no inline-tag resolution: the class docblock's second line becomes the command summary in craft help, the prose up to the first @tag becomes the help <command> body, each action method's docblock first line becomes that action's description, and option properties' @var text becomes --option help. Therefore, in every class extending yii\console\Controller: no {@see}/{@link}/{@inheritdoc} in class/property docblocks or action-method first lines (write command ids as plain text instead), no ========= section-header rule as docblock line 2 (it prints as the command summary), no hanging-indent continuation lines on option @var tags (they render as ragged indents; match craftcms/cms ResaveController's flush style), and the summary sentence goes on line 2. Verify with ddev craft help <plugin> — craft help | grep '{@' must return nothing.
Three specifics that survive that rule and still bite:
parseDocCommentSummary() (yii\console\Controller) returns trim($docLines[1]) and nothing more — a first sentence that wraps onto line 3 prints only the fragment on line 2 (real summaries truncated at "and writes", "whether every row", "prints the new"). Keep the whole summary sentence on one physical line, even if it exceeds the usual wrap width. on line 2 becomes the craft help` summary verbatim.** Replace generator boilerplate with a real summary before shipping.getHelp() returns the summary verbatim, the command has no help body at all. Prefer docblocks alone; if an override exists (or you add one), delete or align the prose it shadows, and check help <command> prints an actual body.Before every commit:
ddev composer check-cs passesddev composer phpstan passes@throws chains verifiedCraft::t() strings, labels, CP notices, README, docs)~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.