All skills

Craft CMS 5 plugin and module development — extending Craft with PHP. Covers elements, element queries, services, models, records, controllers, migrations, queue jobs, console commands, field types, native fields, events, behaviors, Twig extensions, widgets, filesystems, permissions, project config, GraphQL, testing, and debugging. Triggers on: beforePrepare()/afterSave()/defineSources()/defineTableAttributes()/attributeHtml(), MemoizableArray, BaseNativeField, EVENT_REGISTER_*/DEFINE_*/BEFORE_*/AFTER_*, CraftVariable, custom element or field type (normalizeValue/serializeValue/inputHtml), webhook, API endpoint, queue/batch job, CP section, dashboard widget, utility page, element action/exporter/condition, registerUserPermissions, requirePermission vs requireAdmin, kebab-case permission handles, allowAdminChanges, canView/canSave/canDelete, defineRules, elevated session, project-config/apply, drafts/revisions, element edit sidebar (EVENT_DEFINE_SIDEBAR_HTML) + toolbar buttons, metaFieldsHtml, VueAdminTable, GeneralConfig, getIsMultiSite/refreshSites stale after creating a site, deleteSite phantom sites, element query stops filtering by siteId, naive UTC datetime columns and strtotime drift, element-query ['like'] tuple matches nothing, configWarning config-file overrides. Always use when writing, editing, or reviewing Craft plugin/module PHP — even when no API is named. For plugin-specific work also load craft-plugins. Do NOT trigger for front-end Twig (craft-site) or content modeling (craft-content-modeling).

Use this Skill: https://skilld.dev/gh/michtio/craftcms-claude-skills/craftcms

This session only. Nothing lands on disk.

referencesquality.md

≈6.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Quality -- ECS, PHPStan, Rector, CI/CD

Static analysis, code style enforcement, and continuous integration patterns for Craft CMS 5 plugins and modules.

Canonical config source: the full ecs.php / phpstan.neon configuration and the Craft::$app typed-local narrowing pattern live in the craft-php-guidelines skill (references/tooling.md). This document covers Craft-specific usage (running the tools, reading errors, CI wiring, baselines, testing) rather than re-declaring the config files.

Documentation

Contents

Common Pitfalls

  • Running fix-cs project-wide without explicit approval -- can touch hundreds of files across vendor code, unrelated modules, and generated files. Always scope to changed files or specific directories.
  • Adding new code errors to the PHPStan baseline instead of fixing them -- the baseline is for legacy code only. New code ships clean.
  • Skipping ECS on generated/scaffolded code -- Craft's generators (ddev craft make) don't always match your project's style rules. Run check-cs on every new file.
  • Not matching PHPStan level to team capability -- jumping to level 8 on a team unfamiliar with static analysis creates frustration. Start at 5 (recommended for Craft plugins), raise incrementally.
  • Using @phpstan-ignore-next-line without a comment explaining why -- makes it impossible to revisit later.
  • Not running ddev composer check-cs AND ddev composer phpstan before every commit.
  • Running fix-cs in CI instead of check-cs -- the auto-fix rewrites files inside the runner, the job exits 0, and the violations are discarded with the runner. The step's whole purpose is to fail. Auto-fixing belongs on a developer machine or a pre-commit hook.
  • Shipping a workflow with no test job -- static analysis passing is not the same as the suite passing, and a suite that isn't CI-enforced decays unnoticed. Add a Pest step, invoked from the plugin's own root.
  • Pest test names that don't describe the behavior -- it('works') tells nothing.

ECS Deep Dive

What craftcms/ecs provides

The craftcms/ecs package ships a pre-configured ECS rule set aligned with Craft's coding guidelines: section header spacing, PHPDoc formatting, import ordering, brace style.

Install: ddev composer require --dev craftcms/ecs

Configuration file structure

ECS is configured via ecs.php at the project root. The file defines rule sets (SetList::CRAFT_CMS_4 — the correct set for Craft 5; there is no CRAFT_CMS_5), skip patterns, and paths to check.

The canonical ecs.php config lives in the craft-php-guidelines skill (references/tooling.md). Copy it from there rather than maintaining a second version here.

Running ECS

Check for violations (read-only):

ddev composer check-cs

Auto-fix violations (writes files):

ddev composer fix-cs

Scope fix to specific files (safe for individual changes):

ddev exec vendor/bin/ecs check src/services/Items.php --fix
ddev exec vendor/bin/ecs check src/controllers/ --fix

Never run fix-cs project-wide without explicit approval. Scope to changed files only.

Common ECS violations in Craft context

Violation Fix
Missing blank line before return Add blank line before return statement
Imports not alphabetized Reorder use statements alphabetically
PHPDoc missing @param type Add type annotation to all parameters
Trailing comma missing in multiline Add trailing comma after last array/parameter entry
No space after cast Add space: (int) $value not (int)$value
Blank line after opening brace Remove blank line after { in class/method
Multiple blank lines Collapse to single blank line
Missing void return type Add : void to methods that return nothing
Yoda comparison style Craft uses Yoda: null === $value not $value === null

Customizing rules

Skip rules or configure them per-project in ecs.php:

->withSkip([
    PhpCsFixer\Fixer\Phpdoc\PhpdocAlignFixer::class,
    __DIR__ . '/src/migrations/Install.php',
])
->withConfiguredRule(
    PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer::class,
    ['order' => ['param', 'return', 'throws']],
)

PHPStan Levels Reference

Level What it checks
0 Basic checks: unknown classes, functions, methods called on objects
1 Possibly undefined variables, unknown magic methods, unknown properties on $this
2 Unknown methods on union types ($foo could be A|B, method must exist on both)
3 Return types checked against declared return type
4 Basic dead code detection (always-true/false conditions)
5 Argument types checked on method/function calls (recommended for Craft plugins)
6 Missing typehints reported (properties, parameters, return types)
7 Union types fully enforced, partial matches flagged
8 Nullable types strictly enforced, method calls on possibly-null values flagged
9 Mixed type strictness -- operations on mixed flagged unless narrowed
10 Even stricter mixed handling, including implicit mixed from missing types (PHPStan 2.0+)

--level max is an alias for the highest level (currently 10).

Recommendation: Start at level 5 for new plugins. It catches real bugs (wrong argument types) without drowning you in missing-typehint noise from Craft's loose-typed APIs (largely magic getters on Yii Components). Raise incrementally as the codebase matures — level 8 enforces null-strictness which is genuinely valuable; level 9-10 push into territory where the noise/value ratio gets thin on plugin code. The Craft community baseline is 5.

craftcms/phpstan Package

Every Craft 5 plugin should use the official PHPStan extension. Without it, PHPStan cannot understand Craft's service locator, element queries, or component accessors — and hand-rolled scanFiles / scanDirectories pointed at vendor/craftcms/cms/src or vendor/yiisoft/yii2 will silently miss the stubs.

What it ships (verified against craftcms/phpstan):

  • Stubs for yii\BaseYii, craft\web\Application, craft\console\Application. The BaseYii.stub declares Craft::$app as the non-nullable union \craft\web\Application|\craft\console\Application. Without it, PHPStan at level 5+ flags every Craft::$app->getView() / getRequest() / getConfig() / getElements() call as "call on possibly null" or "undefined method on Yii union" — the union those methods live on (craft\base\ApplicationTrait) isn't visible without the stub.
  • scanFiles entries for Craft.php, Yii.php, and Twig's CoreExtension.php so static analysis sees those globals/macros.
  • earlyTerminatingMethodCalls for Craft::dd, yii\base\Application::end, and yii\base\ErrorHandler::convertExceptionToError — code after those calls is correctly treated as unreachable.

Install:

"require-dev": {
    "craftcms/phpstan": "dev-main"
}

Then ddev composer update craftcms/phpstan.

Include in phpstan.neon (before your baseline):

includes:
    - vendor/craftcms/phpstan/phpstan.neon

That's the full required wiring. Don't roll your own scanFiles / scanDirectories pointed at Craft or Yii source — the extension is the canonical reference and removes variance. The failure mode otherwise is a paradox: a hand-rolled config sometimes passes locally (cached PHPStan results, an older craftcms/cms in vendor/) and then fails on a fresh CI install when newer Craft versions tighten type declarations.

PHPStan Configuration

The canonical phpstan.neon config (includes, level: 5, treatPhpDocTypesAsCertain: false, tmpDir, ignoreErrors) lives in the craft-php-guidelines skill (references/tooling.md). Copy it from there. The notes below cover Craft-specific usage — when each setting matters and how to read the errors — not the config file itself.

Run: ddev composer phpstan

Why treatPhpDocTypesAsCertain: false

The craftcms/phpstan stubs declare Craft::$app as \craft\web\Application|\craft\console\Application — a non-nullable union. That assertion holds during normal request handling but is wrong in two situations:

  1. Unit tests that don't bootstrap a full Craft app. A Pest/PHPUnit test that instantiates a service directly to test a pure-PHP method may run with Craft::$app === null.
  2. Very early bootstrap, before BaseYii::createApplication() has run.

If your code carries defensive guards like:

if (!Craft::$app instanceof WebApplication && !Craft::$app instanceof ConsoleApplication) {
    return;
}

PHPStan with the stubs flags the second clause as "always false" because the stub asserts $app is one of those two types. Setting treatPhpDocTypesAsCertain: false tells PHPStan to treat stub-declared types as advisory rather than certain, so the guards remain live code rather than dead branches.

Only set it when you actually carry these guards. If every code path in your plugin runs inside a request and you don't need null-defensive code, leave the setting off and let the stubs do their job.

Common fix patterns

ActiveQuery returns array:

/** @var MyRecord[] $records */
$records = MyRecord::find()->where(['categoryId' => $id])->all();

Element query returns ElementQueryInterface:

/** @var MyQuery $query */
$query = MyElement::find();

Yii2 component access:

/** @var MyPlugin $plugin */
$plugin = MyPlugin::getInstance();

$sourceElements type narrowing in eager loading:

/** @var MyElement[] $sourceElements */
foreach ($sourceElements as $element) {
    // PHPStan now knows custom properties
}

@phpstan-ignore-next-line

Use sparingly, always with explanation:

/** @phpstan-ignore-next-line Craft's event system uses mixed types */
$event->types[] = MyElement::class;

Common PHPStan Errors

Error Cause Fix
Method X not found on Element Missing @method annotation on element class Add @method to class PHPDoc
Cannot call method on null Craft::$app->getXxx() nullable return Add null check or @var annotation
Parameter expects string, int given $request->getBodyParam() returns mixed Cast: (int) $request->getBodyParam('id') or validate
Property has no type declaration Missing typed property Add typed property: public string $handle = '';
Dead catch -- Exception never thrown Overly broad catch block Narrow to specific exception class
Access to undefined property Custom element properties not declared Add typed properties to element class
Access to undefined property $queue Queue runner injects $this->queue dynamically Add @property \yii\queue\Queue $queue to class docblock
Access to an undefined property via __get() Using $plugin->settings or $app->view magic access Use explicit getters: $plugin->getSettings(), $app->getView(). PHPStan can't resolve __get() calls.
Return type has no value type Generic types like array without @return Use @return array<string, mixed> in PHPDoc
Method should return X but returns Y Mismatched declared vs actual return type Fix return type or method logic
Comparison always evaluates to true Redundant null check on non-nullable Remove the check, or fix the type annotation
Unsafe usage of new static() Called in non-final class Mark class final or use @template annotation

Baseline Management

Purpose

The baseline captures existing PHPStan errors so they don't block CI. New code must be clean -- baseline is for legacy code only.

Generate baseline

ddev exec vendor/bin/phpstan analyse --generate-baseline

This creates phpstan-baseline.neon. Include it in phpstan.neon after the craftcms/phpstan include.

Review and enforcement

Diff the baseline file during code review. Watch for:

  • Growing error count -- someone added errors instead of fixing them
  • New file paths -- new code should never appear in the baseline
  • Bulk-fixable patterns -- missing return types, etc.

CI rule: if grep -c 'message:' phpstan-baseline.neon grows, the PR should not merge.

Chipping away

Periodically fix errors, verify with ddev composer phpstan, then regenerate:

ddev exec vendor/bin/phpstan analyse --generate-baseline

Rector

Rector automates PHP refactoring -- upgrading syntax, renaming methods, adjusting type hints. The craftcms/rector package provides Craft CMS-specific upgrade rules (e.g., Craft 4 to 5 migration).

Install: ddev composer require --dev craftcms/rector:dev-main

Always dry-run first, then apply after review:

ddev exec vendor/bin/rector process src --dry-run
ddev exec vendor/bin/rector process src

Configuration (rector.php)

<?php

declare(strict_types=1);

use craft\rector\SetList;
use Rector\Config\RectorConfig;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([SetList::CRAFT_CMS_50]);

Craft-specific rules rename deprecated methods, update event class references, adjust element query signatures, and convert old permission strings. Always run ECS and PHPStan after Rector -- automated refactoring can introduce style violations.

CI/CD Integration

A published Craft 5 plugin typically ships with two GitHub Actions workflows in .github/workflows/:

  1. code-analysis.yaml — runs ECS, PHPStan, and tests on every PR and push. The quality gate.
  2. create-release.yml — listens for a repository_dispatch event from the Craft Console plugin store and creates the GitHub release with the supplied notes. The publish path.

Missing either one ships an unfinished plugin: without code-analysis, regressions land silently; without create-release, every Craft Console publish leaves you with a git tag but no GitHub release page, and users clicking through release links land on a 404.

code-analysis.yaml

name: Code Analysis

on:
  pull_request: null
  push:
    branches:
      - main          # or develop-v5, develop, whatever your release branch is
  workflow_dispatch:

permissions:
  contents: read

jobs:
  code_analysis:
    name: PHP ${{ matrix.php }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        php: ['8.2', '8.3', '8.4']
    steps:
      - uses: actions/checkout@v4
      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: /tmp/composer-cache
          key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.json') }}
          restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: 'ctype,curl,dom,iconv,intl,json,mbstring,openssl,pcre,pdo,reflection,spl,zip'
          ini-values: post_max_size=256M, max_execution_time=180, memory_limit=512M
          tools: composer:v2
      - name: Install Composer dependencies
        # --no-blocking lets the install proceed despite upstream advisories on
        # Craft's transitive deps (yii2, graphql-php). They're Craft's surface
        # to fix; this plugin can't and shouldn't gate its own CI on them.
        run: composer install --no-interaction --no-ansi --no-progress --no-blocking
      - name: PHPStan
        run: composer phpstan
      - name: Coding Standards
        run: composer check-cs
      - name: Pest
        run: composer test

Notes:

  • check-cs, never fix-cs. In CI the auto-fix rewrites files in the runner and exits 0 — green job, discarded fixes, violations nobody sees. The step exists to fail.
  • The Pest step is not optional. A suite that isn't CI-enforced rots at the speed of the codebase; one real plugin carried a 74-test suite that nothing ever ran. Invoke it from the plugin's own root (for a plugin repo the checkout is that root, so plain composer test is correct — in a monorepo or host-project workflow add working-directory:). This matters beyond hygiene: craft-pest-core reads its DB pins from getcwd(), so a suite invoked from elsewhere runs against the wrong database. See the craft-pest skill's ci.md for the database service block and the full rationale.
  • PHP matrix matters. Craft 5 supports PHP 8.2–8.4. Test all three; 8.4 surfaces deprecation warnings that 8.2 doesn't, and 8.2 catches accidental use of features that only landed in 8.3/8.4.
  • --no-blocking is the canonical flag. Composer 2.5+ refuses to install packages flagged in the security-advisories registry by default. Craft 5.9.x transitively depends on yiisoft/yii2 2.0.54 (CVE-2026-39850, fixed in 2.0.55) and webonyx/graphql-php 14.11.10 (covered by advisories on <=15.32.2). Both are upstream concerns Craft will pick up on its next release; a downstream plugin can't fix them. The flag bypasses the block.
  • Legacy flag name. Older Composer versions used --no-security-blocking. Composer's own docs deprecated that in favour of --no-blocking. Existing workflows using the old name still work but should be updated.
  • Don't substitute --no-audit. Different flag with different scope: --no-audit suppresses the post-install audit report, but it doesn't bypass the install-time blocking policy. You'll still hit the advisory error.
  • Plugins that don't ship a composer.lock (the common library pattern, since the consuming project pins versions) hit this on every fresh CI run. Plugins that do commit a lockfile only hit it when they regenerate.
  • pull_request: null is a deliberate shorthand for "run on all PR events with default config" — equivalent to pull_request: {}.

create-release.yml

When Craft Console (the plugin marketplace) publishes a new version of your plugin, it sends a repository_dispatch event with the release metadata. This workflow listens for it and creates the matching GitHub release:

name: Create Release
run-name: Create release for ${{ github.event.client_payload.version }}

on:
  repository_dispatch:
    types:
      - craftcms/new-release

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: ncipollo/release-action@v1
        with:
          body: ${{ github.event.client_payload.notes }}
          makeLatest: ${{ github.event.client_payload.latest }}
          name: ${{ github.event.client_payload.version }}
          prerelease: ${{ github.event.client_payload.prerelease }}
          tag: ${{ github.event.client_payload.tag }}

What's happening:

  • Trigger: repository_dispatch with the literal event type craftcms/new-release. Craft Console fires this when you cut a release through their UI.
  • Payload: github.event.client_payload carries version, notes, latest, prerelease, tag — all populated from your Craft Console release form (the notes come from the matching CHANGELOG section that Craft parsed).
  • Action: ncipollo/release-action@v1 creates the GitHub release with the supplied tag and notes, marking it latest/prerelease according to the payload.
  • Permissions: the job needs contents: write to create releases. The default token's contents: read isn't enough.

The release workflow doesn't need a Composer install or test run — the assumption is code-analysis.yaml already gated the merged code. The release flow is purely a "wrap the tag in a release object" operation.

Order rationale (code-analysis)

  1. PHPStan / ECS (seconds to a minute) — fail fast on type errors and formatting before expensive test runs
  2. Pest — integration tests last, only if static checks pass

Whether to run ECS or PHPStan first is preference; both fail fast on cheap signals. craft-tailwind runs PHPStan first; the previous version of this doc recommended ECS first. Either is defensible.

Caching

  • Composer vendor — keyed by composer.json hash (or composer.lock if your plugin ships one).
  • PHPStan result cache — keyed by commit SHA with branch prefix fallback. Optional but cuts repeated-PR runtime substantially. Add a separate actions/cache@v4 step pointing at tmp/phpstan and reference it from phpstan.neon via tmpDir.

Parallel jobs for larger projects

Split ECS and PHPStan into separate jobs, gate tests with needs: [ecs, phpstan].

Pre-Commit Hooks

Run ECS on staged PHP files before every commit. Create .git/hooks/pre-commit (and chmod +x it):

#!/bin/sh

STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
[ -z "$STAGED_PHP" ] && exit 0

echo "Running ECS on staged PHP files..."
# Use 'ddev exec vendor/bin/ecs' in DDEV environments,
# or 'vendor/bin/ecs' for CI / non-DDEV setups
echo "$STAGED_PHP" | xargs ddev exec vendor/bin/ecs check

if [ $? -ne 0 ]; then
    echo "ECS failed. Run 'ddev composer fix-cs' to auto-fix, then re-stage."
    exit 1
fi

For projects with a Node toolchain, use husky + lint-staged instead:

{
  "lint-staged": {
    "*.php": "ddev exec vendor/bin/ecs check"
  }
}

Testing

Load the craft-pest skill for everything test-related — harness setup, database isolation, factories, HTTP/queue/DB assertions, mocking, console and event tests, and CI test jobs.

{
    "require-dev": {
        "markhuot/craft-pest-core": "^3.0"
    }
}

Run from the plugin's own root: composer test (or ddev exec --dir /var/www/html/vendor/acme/my-plugin vendor/bin/pest). Running a plugin suite from a shared project root with --configuration= silently discards the plugin's database pins — see the craft-pest skill's isolation.md.

Generated Artifacts Must Not Read Runtime Registries

A generator that emits committed or published artifacts — reference docs, JSON schemas, TypeScript types, fixtures — by reading a live Craft install's registries produces output that depends on which install ran it. Two real failure modes, both shipped or nearly shipped:

  • Edition-gated registries. A generator read a registry whose contents are gated on the plugin edition, so the output differed depending on the edition the generator happened to run on. Fix: a generator-only accessor (e.g. docsInputSchema()) that defaults to the runtime registry and is overridden only where gating applies — runtime validation stays untouched, and the generated artifact is edition-independent.
  • Registries that accumulate install state. A generator read a registry whose second pass appends user-authored content stored as elements, so regenerating on a real install would have written that install's own content into the published artifact. Fix: a getBundled() that captures only what the plugin ships — and capture it before any EVENT_REGISTER_* hook fires, or third-party registrations leak in the same way.

Additionally, a generated artifact whose content depends on a vendor tree should state the resolved version it was generated against (e.g. in a header comment), or its numbers churn per machine with no visible cause.

Code Review Checklist

For the full PHP coding standards, see the craft-php-guidelines skill. Key checks before every commit:

  1. ddev composer check-cs passes
  2. ddev composer phpstan passes
  3. Tests green
  4. PHPDocs complete -- classes, methods, properties, @throws chains
  5. Section headers present with ========= separators
  6. Imports alphabetical and grouped
  7. Permissions on all controller actions
  8. requirePostRequest() on mutating actions
  9. addSelect() convention in beforePrepare() (additive across extensions)
  10. Events fired for extensibility
  11. MemoizableArray cache invalidated on data changes
  12. No N+1 queries -- eager loading used where needed
  13. Large operations pushed to queue
  14. Conventional commit message prepared

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The craftcms skill is a comprehensive reference guide for Craft CMS 5 plugin and module development. It provides extensive documentation on security best practices, including authorization layers, data handling, and common architectural pitfalls. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at 614a89e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for michtio/craftcms-claude-skills/craftcms