michtio/craftcms-claude-skills/craft-php-guidelines
Claude Code and agent skill by michtio/craftcms-claude-skills
- community
Checking that the SKILL.md file is still in the repository.
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, DateTimeHel…
Install
npx vskill@latest install michtio/craftcms-claude-skills/craft-php-guidelinesSKILL.md
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 inbeforePrepare()— safely additive when multiple extensions contribute columns.$_instancesis 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, notResaveElementsJob. declare(strict_types=1)is NOT used in plugin source files. Only in standalone config files likeecs.php.@authorgoes on classes and methods only — never on properties. (Craft core puts@authorat 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 indefineRules()— Craft core uses string method names:[['attr'], 'validateAttr']. The validator method is public, no underscore — Yii invokes it by name. DateTimeHelperin elements/queries,Carbonin services — never mix in the same class.- Parsing a raw DB datetime with
strtotime()ornew DateTime()— those columns are naive UTC strings and the process timezone issystem.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
@throwschains — 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 constacross multiple classes with "keep in lockstep" comments — PHPStan can't detect drift. Declarepublic conston the owning service, reference asOwnerService::CONSTANT_NAMEeverywhere 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 apublic conston 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 thecraftcmsskill'spermissions.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'scomposer.json— resolution works only on the author's disk. Unpublished sibling deps get avcsentry; Packagist deps need nothing;composer.lockstays gitignored for plugins. Prove it withcomposer config --global repositories(expect empty) then a no-lockcomposer update --dry-run. Seereferences/tooling.md(Composer Hygiene). - Using
Db::parseParam()for a literal comparison — a leading or trailing*becomes a SQLLIKEwildcard, so a uniqueness check on a stored pattern silently becomes a prefix match. Use a rawandWhere(['col' => $value]). See thecraftcmsskill'sarchitecture.md. - Registering
EVENT_REGISTER_ELEMENT_TYPES/EVENT_REGISTER_FIELD_TYPESinside agetIsCpRequest()(or other request-context) branch ininit()— component-type registration must run in every context (CP, console, site) or the type disappears fromgetAllElementTypes()in console/queue requests, andGc::hardDeleteElements()silently stops purging its trashed rows. Register unconditionally; only CP-rendering/routing (URL rules, asset bundles, nav) may be gated. See thecraftcmsskill'sevents.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
- PHPDocs on everything: classes, methods, properties. No exceptions.
@throwschains: document every exception including uncaught from called methods.@authorand@sinceat 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// Statusesor// 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 inbeforePrepare()— additive across extensions, prevents column conflicts.DateTimeHelperin elements/queries,Carbonin 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-csandddev composer phpstanmust 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/ecswithSetList::CRAFT_CMS_4preset (covers both Craft 4 and 5).- Short nullable notation:
?stringnotstring|null. - Always specify
voidreturn types. - Typed properties everywhere. No untyped public properties.
- Strict comparison always:
$foo === null,in_array($x, $y, true). - Casts over functions:
(int)$foonotintval($foo).
Section Header Order
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
switchwhen a singleifsuffices";switchremains acceptable where it reads more clearly (and is common in core). - Always use curly brackets even for single statements.
- Separate compound conditions into nested
ifstatements 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 tosystem.timeZone— sostrtotime()or barenew 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. UseDateTimeHelper::toDateTime($value)(naive input is assumed UTC by default) orCarbon::createFromFormat('Y-m-d H:i:s', $value, 'UTC'). See thecraftcmsskill'sarchitecture.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, returnsstring|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 theuse craft\helpers\App;line. App::parseEnv()when the stored value may be a$VARreference or an alias (settings-model getters resolving env-able fields). It resolves a whole-string$VAR, a$VAR/subpath(5.5+), a$VARin any slash-delimited segment or${VAR}anywhere in the string (5.9+); nothing else. 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"; useApp::env()for the latter.
Database Conventions
This is the start of the file. Read the full SKILL.md on GitHub.
Security
- Security scanPassed, 100 of 100Pattern checks on the skill's files.
- Model reviewNot runA model review is scheduled for the next rescan.
Quality evaluation
- Task evaluationNot runNo evaluation yet. Run one in Skill Studio.
Source
- SKILL.md in the repositoryCheckingskills/craft-php-guidelines/SKILL.md
Popularity
- Repository stars
- 87
- Repository forks
- 9
- 7-day trend score
- 21
Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.
Works with
Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.
- Claude Code
- Cursor
- GitHub Copilot
- Windsurf
- Codex
- Gemini CLI
- Cline
- Amp
- Roo Code
- Goose
- Continue
- Kilo Code
- Junie
- Trae
- OpenHands
- Qwen Code
- AI Agent
- Tabnine Chat
- Sourcegraph Cody
- Amazon Q
- Firebase Genkit
- Sweep
- Devin
- Phind
- Mentat
- Claude Desktop
- Aider
- GPT Pilot
- Plandex
- bolt.new
- v0
- Replit Agent
- Anse
- TypingMind
- BetterChatGPT
- LibreChat
- Warp AI
- Pieces
- Fig AI
Embed badge
[](https://verified-skill.com/skills/michtio/craftcms-claude-skills/craft-php-guidelines)