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.

referencesdebugging.md

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

Debugging & Performance

Documentation

Common Pitfalls

  • Element query inside a loop without eager loading -- classic N+1.
  • Using ->all() when you only need ->count(), ->ids(), or ->exists().
  • Choosing ElementQuery when ActiveRecord or direct Query builder would be more appropriate.
  • Forgetting ->status(null) on cached element queries -- status changes over time create different cache keys.
  • Using Yii's $query->batch() instead of Db::each() -- Yii's version doesn't handle unbuffered MySQL properly.
  • Not disabling Xdebug when not debugging -- significant overhead even when idle.
  • Transform generation during page load -- push to queue or pre-generate.
  • Wrapping dynamic content in {% cache %} tags -- stale output for logged-in users, CSRF tokens, live dates.
  • Using entry.relatedEntries.all() inside a loop instead of eager loading on the parent query.
  • Calling craft.entries.section('x').all()|length to count -- loads all element objects into memory just to count them.

Contents

Query Strategy: When to Use What

Three query approaches, each with different tradeoffs:

Element Queries -- for element data you'll display or manipulate

The full Craft element system: field values, statuses, drafts, permissions, eager loading. Use when you need elements as objects with their full behavior:

$entries = MyElement::find()
    ->status('live')
    ->categoryId($categoryId)
    ->orderBy('postDate DESC')
    ->limit(10)
    ->all();

Performance shortcuts on element queries:

->count()     // SELECT COUNT(*) -- don't load objects just to count them
->ids()       // SELECT id only -- when you just need IDs
->exists()    // SELECT 1 LIMIT 1 -- existence check
->pairs()     // Key-value pairs from two columns
->asArray()   // Skip Element model hydration, return plain arrays

ActiveRecord -- for plugin config tables (non-element data)

Thin table mapping for your plugin's own config/settings tables (instances, match fields, site settings). Not for element data:

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

ActiveRecord gives you model objects with save(), delete(), and relation definitions, but no element lifecycle. Use for the handleChangedItem() pattern in project config handlers.

Direct Query Builder -- for performance-critical joins and aggregates

When you need complex JOINs, aggregations, subqueries, or bulk operations where neither ElementQuery nor ActiveRecord is appropriate:

$stats = (new Query())
    ->select([
        'categoryId',
        'COUNT(*) as total',
        'SUM(CASE WHEN [[status]] = :live THEN 1 ELSE 0 END) as liveCount',
    ])
    ->from(Table::MY_ELEMENTS)
    ->groupBy('categoryId')
    ->addParams([':live' => 'live'])
    ->all();

Also use for eagerLoadingMap() implementations -- these need raw source-to-target ID mappings, not full element objects:

$map = (new Query())
    ->select(['source' => 'id', 'target' => 'relatedItemId'])
    ->from(Table::MY_ELEMENTS)
    ->where(['id' => $sourceIds])
    ->all();

Decision Guide

Need Use
Elements with field values, statuses, permissions Element Query
Plugin config/settings records (non-element tables) ActiveRecord
Aggregations, complex JOINs, bulk reads Query builder
Eager loading maps, raw ID lookups Query builder
Project config handlers (handleChanged) ActiveRecord
Element counts, ID lists, existence checks Element Query with ->count() / ->ids() / ->exists()

Eager Loading (Plugin-Side)

Custom Eager Loading Map

For custom elements with relations to other elements, implement eagerLoadingMap():

public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false
{
    if ($handle === 'relatedItems') {
        $sourceIds = array_map(fn($el) => $el->id, $sourceElements);
        $map = (new Query())
            ->select(['source' => 'id', 'target' => 'relatedItemId'])
            ->from(Table::MY_ELEMENTS)
            ->where(['id' => $sourceIds])
            ->all();

        return ['elementType' => RelatedItem::class, 'map' => $map];
    }

    return parent::eagerLoadingMap($sourceElements, $handle);
}

Pre-loading in Services

When your service returns elements that consumers will iterate over, pre-load relations:

public function getItemsWithRelations(int $categoryId): array
{
    return MyElement::find()
        ->categoryId($categoryId)
        ->with(['relatedItems', 'relatedItems.thumbnail'])
        ->all();
}

Twig-Side (Front-End Templates)

Craft 5 introduced lazy eager loading via .eagerly() -- automatically batches relation loading in template loops. .with() supports nested dot notation for manual pre-declaration. These are template patterns, not plugin-side, but worth knowing when your element type is consumed in Twig.

Indexes and Foreign Keys

Design your migration indexes around how your element queries filter:

// Compound index for the most common query pattern
$this->createIndex(null, Table::MY_ELEMENTS, ['categoryId', 'externalId'], true);

// Single-column indexes for date-based filtering and sorting
$this->createIndex(null, Table::MY_ELEMENTS, ['postDate']);
$this->createIndex(null, Table::MY_ELEMENTS, ['expiryDate']);

Foreign keys with appropriate ON DELETE behavior:

// Element ownership -- cascade delete when element is hard-deleted
$this->addForeignKey(null, Table::MY_ELEMENTS, ['id'], CraftTable::ELEMENTS, ['id'], 'CASCADE', null);

// Reference to config entity -- null out when config entity deleted
$this->addForeignKey(null, Table::MY_ELEMENTS, ['categoryId'], Table::CATEGORIES, ['id'], 'SET NULL', null);

Batch Processing

Db::each() for Large Datasets

Craft's wrapper handles unbuffered MySQL connections:

use craft\helpers\Db;

$query = MyElement::find()->categoryId($categoryId)->site('*')->status(null);

foreach (Db::each($query) as $element) {
    // Process one at a time, constant memory
}

// Or in batches of 100
foreach (Db::batch($query, 100) as $batch) {
    foreach ($batch as $element) {
        // Process batch
    }
}

Memory Management

App::maxPowerCaptain(); // Raises memory_limit, removes max_execution_time

Craft calls this automatically for queue jobs. For console commands, call explicitly at the start of long-running actions.

Logging

Craft::debug('Trace-level, devMode only', __METHOD__);
Craft::info('Info level, devMode only', 'my-plugin');
Craft::warning('Always logged', 'my-plugin');
Craft::error('Always logged', 'my-plugin');

In production, only warning and above are logged. Use the plugin handle as the category for easy filtering.

Log files: storage/logs/web-{date}.log, console-{date}.log, queue-{date}.log. For containers: CRAFT_STREAM_LOG=true.

Forwarding logs over syslog-TLS: frame with octet counting. RFC 5425 §4.3.1 requires octet-counted framing for syslog over TLS — each frame is MSG-LEN SP SYSLOG-MSG, where MSG-LEN is the message's byte length. Newline-delimited frames are RFC 6587 non-transparent framing, a different (legacy) scheme. The trap: rsyslog's imtcp accepts newline framing by default, so a newline-framed sender appears to work against the most common test receiver, then a strict RFC 5425 receiver (or a SIEM ingest endpoint) rejects or corrupts the stream. If a plugin ships a log-forwarding feature, emit octet-counted frames and say so in the docs.

Profiling

Craft::beginProfile('expensiveOperation', __METHOD__);
// ... code to profile ...
Craft::endProfile('expensiveOperation', __METHOD__);

Shows in the Debug Toolbar's Time panel.

Debug Toolbar

Enabled when devMode is true. Users enable it under My Account > Preferences.

Most useful panels:

  • DB panel: All queries, sortable by duration -- fastest way to find N+1.
  • Profiling panel: Custom beginProfile/endProfile blocks and per-template render times.
  • Logs panel: All log messages for the request.
  • Deprecation panel: Deprecation warnings triggered during the request.
  • Mail panel: Emails sent during the request (useful for verifying queue notifications).

Template Debugging

dump() and dd()

Both require devMode to be enabled:

{{ dump(entry) }}                          {# Dumps to debug bar, does not halt #}
{{ dd(entry) }}                            {# Dump and die -- halts execution #}
{{ dump(entry, category, currentUser) }}   {# Multiple variables #}
{{ dump() }}                               {# All variables in current scope #}

Quick JSON inspection

Lightweight alternative to dump() for raw field values:

<pre>{{ entry.myMatrixField|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
<pre>{{ craft.entries.section('blog').limit(3).ids()|json_encode }}</pre>

devMode error display

When devMode is true, Craft shows verbose Yii error pages with full stack traces, query logs, and environment details. When false, Craft renders custom error templates (see Error Handling Patterns). Never leave devMode enabled in production -- stack traces expose file paths, database queries, and environment variables.

Template profiling

The Profiling panel in the debug toolbar breaks down render time per template. Look for templates with high self-render time, templates called many times per request (unoptimized loops), and compilation overhead on first load.

Twig Cache Tag

The {% cache %} tag caches rendered HTML. Craft tracks element queries inside the block and invalidates when those elements change.

Basic usage

{% cache %}
    {% set entries = craft.entries.section('blog').limit(10).all() %}
    {% for entry in entries %}
        <article>{{ entry.title }}</article>
    {% endfor %}
{% endcache %}

Options

Option Example Effect
globally {% cache globally %} Shared across all URLs (default: per-URL)
using key {% cache using key "sidebar-nav" %} Custom cache key
for {% cache for 3600 %} or {% cache for "1 hour" %} Explicit duration
if {% cache if not currentUser %} Conditional caching
unless {% cache unless craft.app.request.isPreview %} Inverse conditional
tag {% cache tag('blog-list') %} Tag-based invalidation group

Options can be combined: {% cache globally using key "footer-nav" for "6 hours" tag('navigation') %}.

Invalidation

Craft automatically tracks every element query inside a {% cache %} block. When those elements change, the cache is invalidated. For manual tag-based invalidation:

ddev craft invalidate-tags/template --tag=blog-list   # Specific tag
ddev craft invalidate-tags/all                         # All template caches

Key notes

  • enableTemplateCaching in config/general.php disables all {% cache %} tags when false. Use in development to avoid stale output. Cross-reference config-general.md.
  • Do not cache dynamic content -- CSRF tokens, user names, timestamps become stale. Move dynamic parts outside the cache block.
  • Cache blocks use the configured cache backend (database default, or Redis/Memcached via app.php).

N+1 Detection and Fixes

What N+1 looks like

1 query fetches a list, then N queries fire in the loop for relations. 50 entries with 1 relation = 51 queries instead of 2.

{# BAD: N+1 -- each iteration fires a query for relatedArticles #}
{% set entries = craft.entries.section('blog').limit(50).all() %}
{% for entry in entries %}
    {% set related = entry.relatedArticles.all() %}
{% endfor %}

How to spot in the debug toolbar

Open the DB panel: many identical query patterns with different IDs = N+1. Sort by "Count" to find repeated templates. A listing page with 200+ queries almost always has an eager loading problem.

Fix with .with() (PHP side)

$entries = Entry::find()->section('blog')->limit(50)
    ->with(['relatedArticles', 'relatedArticles.featuredImage'])
    ->all();

Fix with .eagerly() (Twig side)

{% for entry in entries %}
    {% set related = entry.relatedArticles.eagerly().all() %}
{% endfor %}

Matrix/nested eager loading

->with(['matrixField.blockTypeHandle:relationField', 'matrixField.blockTypeHandle:relationField.thumbnail'])

Query count comparison

Scenario Without With eager loading
50 entries, 1 relation each 51 queries 2 queries
50 entries, 2 relations each 101 queries 3 queries
50 entries + Matrix + nested relation 151+ queries 3-4 queries

Query Logging and Analysis

devMode query logging

When devMode is true, every query is logged to the debug toolbar DB panel automatically.

CRAFT_DB_LOG_SQL environment variable

For SQL logging outside devMode (staging, production debugging):

# .env -- remove after debugging, creates significant log volume
CRAFT_DB_LOG_SQL=1

Logs to storage/logs/ or stdout with CRAFT_STREAM_LOG=true.

Reading the DB panel

  1. Sort by Duration: Queries over 100ms need investigation. Over 1s are highlighted.
  2. Sort by Count: Repeated identical templates with different params = N+1.
  3. Total query count: Typical page load is 20-60 queries. 200+ = eager loading problem.
  4. Explain plans: Click a query to see MySQL EXPLAIN -- watch for type: ALL (full table scan).

Error Handling Patterns

Craft exception types

Exception (craft\errors\) When thrown
ElementNotFoundException Element save fails -- element no longer exists
MissingComponentException Referenced component class not found
SiteNotFoundException Requested site ID/handle does not exist
InvalidFieldException Field handle does not exist on the element
MutexException Could not acquire a mutex lock

Try/catch in services

use craft\errors\ElementNotFoundException;

public function processEntry(int $entryId): bool
{
    try {
        $entry = Entry::find()->id($entryId)->status(null)->one();
        if (!$entry) {
            throw new ElementNotFoundException("Entry $entryId not found");
        }
        Craft::$app->getElements()->saveElement($entry);
        return true;
    } catch (ElementNotFoundException $e) {
        Craft::warning("Skipping missing entry: {$e->getMessage()}", __METHOD__);
        return false;
    } catch (\Throwable $e) {
        Craft::error("Failed to process entry $entryId: {$e->getMessage()}", __METHOD__);
        throw $e;
    }
}

Controller error responses

// CP controller -- JSON or redirect based on Accept header
return $this->asFailure('Could not save the record.');

// Front-end -- throw HTTP exceptions for error templates
throw new \yii\web\NotFoundHttpException('Page not found');
throw new \yii\web\ForbiddenHttpException('Access denied');

Custom error templates

Craft renders error templates based on HTTP status code:

templates/404.twig    -- Not Found
templates/500.twig    -- Internal Server Error
templates/error.twig  -- Fallback for unmatched codes

Use errorTemplatePrefix in config/general.php to organize in a subdirectory (e.g., '_errors/' makes Craft look for templates/_errors/404.twig).

Available variables: statusCode (HTTP code) and message (exception message in devMode, generic status text in production).

Deprecation Tracking

Logging deprecations in plugins

Craft::$app->getDeprecator()->log(
    'myPlugin.oldMethod',           // Unique key -- Craft groups by this
    'The `oldMethod()` method has been deprecated. Use `newMethod()` instead.',
);

Debug toolbar deprecation panel

Shows all deprecation warnings for the request with message, file/line, and stack trace. Also accessible in the CP via Utilities > Deprecation Warnings.

Clearing and managing

ddev craft clear-deprecations   # Clear all stored warnings

Craft 4 to 5 upgrade relevance

Before upgrading: enable devMode, browse all key pages, check the Deprecation Warnings utility, and fix all warnings. Deprecated APIs are removed in the next major version. Pay attention to Twig function/filter changes and element query parameter renames.

Common Anti-Patterns

Anti-Pattern Impact Fix
entry.relatedEntries.all() inside a loop N+1 queries .with(['relatedEntries']) on parent query or .eagerly()
craft.entries.section('x').all()|length Loads all elements to count .count() instead
Transform generation during page load Slow TTFB generateTransformsBeforePageLoad: false in general config
{% cache %} wrapping dynamic content Stale CSRF tokens, user data Move dynamic parts outside cache block
No eager loading on Matrix blocks N+1 on nested relations .with(['matrixField.blockType:relationField'])
entry.fieldHandle without .one() or .all() Returns query object, not results Always terminate with .one(), .all(), or .exists()
{% for entry in craft.entries.section('x').all() %} no limit Loads entire section Add .limit() or paginate
Querying inside a Twig macro called per-iteration Hidden N+1 Pass data into the macro as a parameter
|merge in a Twig loop O(n^2) -- copies array each iteration Build array in PHP, pass to template

Xdebug with DDEV

ddev xdebug on   # Enable step debugging
ddev xdebug off  # Disable when done -- significant overhead

DDEV pre-configures Xdebug 3 on port 9003. For VS Code .vscode/launch.json:

{
    "version": "0.2.0",
    "configurations": [{
        "name": "Listen for Xdebug",
        "type": "php",
        "request": "launch",
        "port": 9003,
        "pathMappings": {
            "/var/www/html": "${workspaceFolder}"
        }
    }]
}

CLI debugging works automatically. Troubleshoot with ddev xdebug-diagnose.

Caching

Data Caches with Tag Dependency

use yii\caching\TagDependency;

$dependency = new TagDependency(['tags' => ['my-plugin:items']]);
$data = Craft::$app->cache->getOrSet(
    'my-plugin:all-items',
    fn() => $this->_expensiveQuery(),
    3600,
    $dependency
);

// Invalidate when data changes
TagDependency::invalidate(Craft::$app->cache, 'my-plugin:items');

Element Query Cache

$results = MyElement::find()
    ->status('live')
    ->cache(60)  // Auto-invalidates via ElementQueryTagDependency
    ->all();

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