Craft CMS 5 PHP coding standards and conventions. ALWAYS load when writing, editing, reviewing, or discussing any PHP in a Craft plugin or module — even small edits. Also when running ECS, PHPStan, or scaffolding with ddev craft make. Covers: PHPDoc blocks (@author, @since, @throws chains), section headers (=========), class organization, naming conventions (services, queue jobs, records, events, enums), defineRules() and validation, beforePrepare() and addSelect(), MemoizableArray, DateTimeHelper vs Carbon, strict_types/declare(strict_types=1), short nullable notation (?string), typed properties, void returns, control flow (early returns, match over switch), CP Twig template conventions, form macros, translations (Craft::t), ECS/PHPStan config, scaffolding commands, and the verification checklist. Triggers on: writing service classes, models, controllers, elements, element queries, records, queue jobs, migrations, or any PHP class in a Craft context; PHP code review, refactoring, or style questions; requireA
Craft CMS 5 PHP coding standards and conventions. ALWAYS load when writing, editing, reviewing, or discussing any PHP in a Craft plugin or module — even small edits. Also when running ECS, PHPStan, or scaffolding with ddev craft make. Covers: PHPDoc blocks (@author, @since, @throws chains), section headers (=========), class organization, naming conventions (services, queue jobs, records, events, enums), defineRules() and validation, beforePrepare() and addSelect(), MemoizableArray, DateTimeHelper vs Carbon, strict_types/declare(strict_types=1), short nullable notation (?string), typed properties, void returns, control flow (early returns, match over switch), CP Twig template conventions, form macros, translations (Craft::t), ECS/PHPStan config, scaffolding commands, and the verification checklist. Triggers on: writing service classes, models, controllers, elements, element queries, records, queue jobs, migrations, or any PHP class in a Craft context; PHP code review, refactoring, or style questions; requireAdmin vs requirePermission, manageSettings, settings permission, allowAdminChanges, read-only settings, getCpNavItem dead nav item, permission handle constant on owning controller, no-em-dash user-facing copy. NOT for front-end Twig (craft-twig-guidelines), template architecture (craft-site), or CP JavaScript/Garnish (craft-garnish). If you are touching PHP in a Craft context, you need this skill.
Craft CMS 5 PHP Guidelines
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).
Companion Skills — Always Load Together
craftcms — Architecture patterns, element lifecycle, controllers, events, migrations. Required for any Craft plugin or module development.
ddev — All commands run through DDEV. Required for running ECS, PHPStan, scaffolding, and tests.
When unsure about a convention, WebFetch the coding guidelines page for the authoritative answer.
Common Pitfalls
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.
Records use the same class name as models (namespace distinguishes). Alias when importing both: use ...\records\MyEntity as MyEntityRecord;.
Queue jobs have no "Job" suffix — 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.)
Don't use string|null — use ?string (short nullable notation).
Forget parent::defineRules() and you lose all inherited validation.
Using [$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.
Missing @throws chains — document exceptions from called methods too, not just your own throws.
Using magic property access ($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.
Calling Craft-specific methods directly on 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.
Duplicating contract constants as 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:manageSettings' 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.)
Registering 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".
Reference Files
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
PHPDocs on everything: classes, methods, properties. No exceptions.
@throws chains: document every exception including uncaught from called methods.
@author and @since at the bottom of class/method docblocks, after a blank line.
Section headers with // ========================================================================= 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.
Private methods/properties prefixed with underscore: _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.
Always scaffold with ddev craft make <type> --with-docblocks, then customize.
ddev composer check-cs and ddev composer phpstan must pass before every commit.
PHP Standards
Minimum PHP 8.2 (Craft CMS 5 requirement).
PSR-12 baseline with Craft modifications (trailing commas, constant visibility).
craftcms/ecs with SetList::CRAFT_CMS_4 preset (covers both Craft 4 and 5).
Short nullable notation: ?string not string|null.
Always specify void return types.
Typed properties everywhere. No untyped public properties.
Only include sections that have content. Blank line after the separator, before the first item.
Control Flow
Happy path last. Handle error conditions first with early returns.
Avoid else — use early returns instead.
Prefer match over switch for value-mapping and returns. The official guideline is "don't use switch when a single if suffices"; switch remains acceptable where it reads more clearly (and is common in core).
Always use curly brackets even for single statements.
Separate compound conditions into nested if statements for readability.
Named arguments when calling methods with 3+ parameters.
Date Handling
Elements and element queries: craft\helpers\DateTimeHelper.
Services (date arithmetic): Carbon\Carbon.
Never mix both in the same class.
Database Conventions
[[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.
Foreign keys with explicit CASCADE / SET NULL behavior.
For the complete naming reference including file structure conventions, read references/naming-conventions.md.
Copy style
Never use em-dashes (—) or en-dashes (–) in user-facing copy: field labels and instructions, Craft::t() strings, CP notices and flash messages, 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.)
Verification Checklist
Before every commit:
ddev composer check-cs passes
ddev composer phpstan passes
Tests green
PHPDocs complete on all new/modified code
@throws chains verified
Section headers present and correct
Imports flat alphabetical (ECS-enforced, not "PHP globals first")
No em-dashes (—) or en-dashes (–) in user-facing copy (Craft::t() strings, labels, CP notices, README, docs)