craft-php-guidelines
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, manage-settings, settings permission, kebab-case permission handles never camelCase, allowAdminChanges, read-only settings, getCpNavItem dead nav item, permission handle constant on owning controller, App::env() never getenv(), App::parseEnv() for $VAR settings, 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.
Works with
---
name: craft-php-guidelines
description: 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, manage-settings, settings permission, kebab-case permission handles never camelCase, allowAdminChanges, read-only settings, getCpNavItem dead nav item, permission handle constant on owning controller, App::env() never getenv(), App::parseEnv() for $VAR settings, 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.
license: MIT
---
# 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.
## Documentation
- Official coding guidelines: https://craftcms.com/docs/5.x/extend/coding-guidelines.html
- Class reference: https://docs.craftcms.com/api/v5/
- Generator reference: https://craftcms.com/docs/5.x/extend/generator.html
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.
- Parsing a raw DB datetime with `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.
- 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: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`.)
- Writing the same authorization check separately in a CP controller, a console command, and a GraphQL resolver — they drift, and the surface that drifts is the one nobody tests. One shared gate method called by every surface, with a test per surface. Console is not exempt (a documented cron path with no permission check is an unauthenticated capability), and GraphQL schema scope is **not** the plugin's permission matrix. See `references/authorization-parity.md`.
- Assuming Craft prevents self-approval / self-review — it has no such concept, and peer permissions are the opposite axis. Write the guard into the shared gate, orthogonal to role checks, with an explicit bypass permission. See `references/authorization-parity.md`.
- Shipping `../*` 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).
- Using `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`.
- 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 | `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` |
## Critical Rules
1. PHPDocs on everything: classes, methods, properties. No exceptions.
2. `@throws` chains: document every exception including uncaught from called methods.
3. `@author` and `@since` at the bottom of class/method docblocks, after a blank line.
4. 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.)
5. `declare(strict_types=1)` is NOT used in plugin source files — Craft's internal type coercion depends on PHP's default weak typing mode.
6. Private methods/properties prefixed with underscore: `_registerCpUrlRules()`, `$_items`.
7. `addSelect()` convention in `beforePrepare()` — additive across extensions, prevents column conflicts.
8. `DateTimeHelper` in elements/queries, `Carbon` in services — separate concerns prevent mixing date APIs in the same class.
9. Always scaffold with `ddev craft make <type> --with-docblocks`, then customize.
10. `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.
- Strict comparison always: `$foo === null`, `in_array($x, $y, true)`.
- Casts over functions: `(int)$foo` not `intval($foo)`.
## Section Header Order
```
// Traits
// Const Properties
// Static Properties
// Public Properties
// Protected Properties
// Private Properties
// Public Methods
// Protected Methods
// Private Methods
```
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.
- **Name the timezone when parsing a value that came out of the database.** Datetime columns hold naive UTC strings (`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).
## Environment Access
- **`craft\helpers\App::env('VAR')`, never `getenv()`** — in plugin code AND in every config-file example that appears in docs. `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).
## 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. 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.
- Foreign keys with explicit `CASCADE` / `SET NULL` behavior.
## Permission Handles
**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`.
## Naming Quick-Reference
| 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`.
## 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, 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.)
## Console controller docblocks are terminal help
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:
- **The summary is docblock line 2 *physically*, not the first sentence.** `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.
- **A scaffolded `* Class FooController` on line 2 becomes the `craft help` summary verbatim.** Replace generator boilerplate with a real summary before shipping.
- **`getHelpSummary()` / `getHelp()` overrides make the docblock dead code.** Once a class overrides them, the docblock and the printed help are two sources of truth that disagree silently — and if `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.
## Verification Checklist
Before every commit:
1. `ddev composer check-cs` passes
2. `ddev composer phpstan` passes
3. Tests green
4. PHPDocs complete on all new/modified code
5. `@throws` chains verified
6. Section headers present and correct
7. Imports flat alphabetical (ECS-enforced, not "PHP globals first")
8. No em-dashes (—) or en-dashes (–) in user-facing copy (`Craft::t()` strings, labels, CP notices, README, docs)More Code Review skills
pr-to-video
heygen-com/hyperframes
Turn a GitHub pull request (a PR URL, owner/repo#N, or 'this PR' in a checked-out repo) into a code-change explainer video — changelog, feature reveal, fix, or refactor walkthrough built from the diff, commits, and files: the input is a code change, not a website. Not a product promo (/product-launch-video) or a no-PR topic explainer (/faceless-explainer). Unclear → /hyperframes.
receiving-code-review
obra/superpowers
Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
public-relations
coreyhaines31/marketingskills
When the user wants help with public relations, earned media, press coverage, journalist outreach, or media strategy (not pull requests). Also use when the user mentions 'PR,' 'public relations,' 'press,' 'press release,' 'press coverage,' 'media outreach,' 'pitch a journalist,' 'get featured,' 'media list,' 'media kit,' 'press kit,' 'newsjacking,' 'news hijack,' 'HARO,' 'Qwoted,' 'Featured,' 'Help A Reporter,' 'reporter request,' 'tech press,' 'TechCrunch,' 'earned media,' 'thought leadership placement,' 'op-ed,' 'guest article,' 'press contacts,' 'podcast prep,' 'going on a podcast,' 'podcast guest,' 'prep me for this podcast,' or 'how do I get press.' Use this for earned media work — finding journalists, pitching stories, newsjacking, prepping podcast appearances, and responding to press requests. For startup/SaaS/AI directory submissions, see directory-submissions. For product launches, see launch. For social-media engagement, see social. For cold-email outreach to prospects, see cold-email.

