---
name: craft-development
description: Craft CMS development expertise for building sites, debugging issues, and providing community support. Use when working with Craft CMS templates (Twig), element queries, GraphQL API, Matrix fields, relational fields, eager loading, caching, plugins, or answering Craft CMS technical questions. Trigger this skill for ANY Craft CMS question -- entry queries, Matrix nesting, relatedTo directions, GraphQL mutations, template debugging, CP issues, `.all()` vs `.one()`, conditional field layouts, or "why does my query return nothing." Also trigger when helping in Craft Discord or Stack Exchange, when the user pastes Twig code with `craft.entries()`, or when debugging N+1 queries or cache invalidation. Covers Craft 4 and Craft 5 patterns including all breaking changes between versions and features through 5.9.
---
# Craft CMS Developer
Patterns and solutions for Craft CMS development. Use this for building sites, debugging, or answering community questions.
## Core Concepts
### Element Queries
All content access starts with element queries. Chain parameters, then execute:
```twig
{% set posts = craft.entries()
.section('blog')
.orderBy('postDate DESC')
.limit(10)
.all() %}
```
**Execution methods:**
- `.all()` - Array of all results
- `.one()` - Single element or null
- `.exists()` - Boolean check
- `.count()` - Integer count
- `.ids()` - Array of IDs only
### Matrix Fields (Most Common Pain Point)
Matrix fields contain nested entries. Query the parent, access nested content:
```twig
{# Get parent entry #}
{% set recipe = craft.entries().section('recipes').slug('cookies').one() %}
{# Access Matrix field - returns entry query #}
{% set steps = recipe.recipeSteps.all() %}
{% for step in steps %}
{# Each block is an entry with its own fields #}
{{ step.title }}
{{ step.instructions|md }}
{% endfor %}
```
**Query entries WITH Matrix content:**
```twig
{# Entries that have at least one nested entry in Matrix field #}
{% set entries = craft.entries()
.myMatrixField(':notempty:')
.all() %}
```
**Craft 5 GraphQL for Matrix:**
```graphql
query RecipeSteps {
entries(section: "recipes") {
... on recipe_Entry {
recipeSteps {
... on step_Entry {
title
instructions
}
}
}
}
}
```
### Relational Queries (relatedTo)
The `relatedTo` parameter finds elements connected via relational fields.
**Direction matters:**
- `sourceElement` - "Find what THIS element points to"
- `targetElement` - "Find what points TO this element"
```twig
{# Products in this category (category is the target) #}
{% set products = craft.entries()
.section('products')
.relatedTo({
targetElement: category,
field: 'productCategories'
})
.all() %}
{# Categories this product belongs to (product is the source) #}
{% set categories = craft.categories()
.relatedTo({
sourceElement: product,
field: 'productCategories'
})
.all() %}
```
**Combining conditions:**
```twig
{# AND - must match ALL #}
{% set results = craft.entries()
.relatedTo(['and', category1, category2])
.all() %}
{# OR (default) - match ANY #}
{% set results = craft.entries()
.relatedTo([category1, category2])
.all() %}
```
### Eager Loading (Performance)
Prevent N+1 queries when accessing related content in loops.
**Upfront eager loading:**
```twig
{% set posts = craft.entries()
.section('blog')
.with([
'featureImage',
'author',
['categories', { limit: 3 }],
])
.all() %}
```
**Lazy eager loading (Craft 5+):**
```twig
{% for post in posts %}
{% set image = post.featureImage.eagerly().one() %}
{% endfor %}
```
**Lazy eager loading with count:**
```twig
{% for recipe in recipes %}
{{ recipe.steps.eagerly().count() }} Step(s)
{% endfor %}
```
**Nested eager loading:**
```twig
{% set entries = craft.entries()
.with([
'matrixField.nestedAssetField',
'matrixField.nestedEntriesField',
])
.all() %}
```
### Entry Type Sharing (Craft 5)
Entry types exist in a global pool. The same entry type can be used in:
- Multiple sections
- Multiple Matrix fields
- Both sections and Matrix fields simultaneously
This eliminates duplication -- define an entry type once, use it everywhere.
## GraphQL Patterns
### Basic Entry Query
```graphql
query BlogPosts {
entries(section: "blog", limit: 10, orderBy: "postDate DESC") {
title
url
postDate @formatDateTime(format: "F j, Y")
... on post_Entry {
summary
featureImage {
url @transform(width: 800, height: 600)
}
}
}
}
```
### Filtering with Custom Fields
```graphql
query FilteredProducts {
entries(section: "products", inStock: true, price: ">= 50") {
title
... on product_Entry {
price
sku
}
}
}
```
### Matrix Field Mutations (Craft 5)
```graphql
mutation SaveRecipe {
save_recipes_recipe_Entry(
title: "New Recipe"
steps: {
entries: [
{
step: {
instructions: "Step 1 content",
id: "new:1"
}
}
],
sortOrder: ["new:1"]
}
) {
id
title
}
}
```
## Craft 4 -> 5 Breaking Changes (Quick Reference)
> For the full migration guide with upgrade checklists, load the `craft-5-migration` skill.
### Empty Array Behavior
**Craft 4:** Empty relatedTo arrays return all results
**Craft 5:** Empty arrays return NO results
```twig
{# WRONG in Craft 5 - returns nothing if categoryIds empty #}
{% set entries = craft.entries()
.relatedTo(categoryIds)
.all() %}
{# CORRECT - check first #}
{% set entries = craft.entries()
.relatedTo(categoryIds|length ? categoryIds : null)
.all() %}
```
### Matrix Block -> Entry Terminology
- "Blocks" are now "nested entries"
- `blocks` parameter -> `entries` in GraphQL mutations
- Block types -> Entry types
### GraphQL Type Names
**Craft 4:** `news_article_Entry`
**Craft 5:** `article_Entry` (section prefix removed)
## Common Debugging
### Query Returns Nothing
1. Check `.status()` - drafts/disabled excluded by default
2. Check `.site()` - multi-site queries need explicit site
3. Check field handles - case-sensitive
4. Log the SQL: `{% dd craft.entries().section('x').getRawSql() %}`
### N+1 Query Issues
Symptom: Slow pages, many DB queries in debug toolbar
Solution: Add `.with([...])` to eagerly load relations
### Cache Not Clearing
Check what triggers invalidation:
- Entry save clears that entry's cache
- Template changes don't auto-clear
- Use cache tags for scoped invalidation:
```twig
{% cache using key "posts" tags ["section:blog"] %}
...
{% endcache %}
```
### Conditional Field Layouts (Craft 5)
Field tabs and individual fields can be shown/hidden based on conditions (entry type, status, user group, custom field values). If fields seem missing in the CP, check the field layout conditions before assuming a bug.
## Recent Features (5.5-5.9)
### New Twig Functions and Filters
```twig
{# Generate random strings and UUIDs (5.9) #}
{% set token = randomString(32) %}
{% set id = uuid() %}
{# Hash with specific algorithm (5.9) #}
{{ 'data'|hash('sha256') }}
{# Encode URLs with special characters (5.5) #}
{{ encodeUrl('https://example.com/path with spaces') }}
```
### New Global Variables
```twig
{# Primary site object (5.6) #}
{{ primarySite.name }}
{# PHP_INT_MAX available (5.6) #}
{% set maxInt = PHP_INT_MAX %}
```
### Element Query Enhancements
```twig
{# Custom field handles in orderBy (5.7) #}
{% set entries = craft.entries()
.section('products')
.orderBy('price ASC')
.all() %}
{# Custom field handles in where conditions (5.6) #}
{% set entries = craft.entries()
.section('products')
.andWhere(['>', 'price', 100])
.all() %}
{# Nested field values in orderBy (5.6) -- e.g. date time zones #}
{% set entries = craft.entries()
.orderBy('myDateField.tz ASC')
.all() %}
{# Only canonical entries, no drafts/revisions (5.7) #}
{% set entries = craft.entries()
.canonicalsOnly(true)
.all() %}
```
### Fallback Element Partial Templates (5.6)
Craft now supports fallback templates for element rendering:
- `_partials/entry/articleType.twig` -- specific to entry type
- `_partials/entry.twig` -- fallback for any entry type
### Redirects in Config (5.6)
Define redirects in `config/redirects.php` instead of the CP:
```php
return [
'old-path' => 'new-path',
'blog/' => 'articles/',
];
```
### Site Header for API Requests (5.6)
Specify the current site via `X-Craft-Site` header (set to site ID or handle) instead of query params.
### GraphQL Updates (5.7-5.9)
```graphql
# Singles get dedicated queries (5.8)
query {
homepageEntry {
... on homepage_Entry {
heroTitle
}
}
}
```
- **Singles queries** (5.8) -- `Entry` queries resolve directly (e.g. `homepageEntry`)
- **Unpublished drafts** (5.9) -- Can create unpublished drafts via GraphQL mutations
- **Search options** (5.7) -- `searchTermOptions` argument for fine-tuned search
- **Provisional drafts** (5.7) -- `withProvisionalDrafts` argument
- **Preview tokens** (5.9) -- `X-Craft-Preview-Token` header for preview requests
## Reference Files
For detailed patterns, see:
- `${CLAUDE_SKILL_DIR}/references/query-cookbook.md` - 30+ real-world query examples
- `${CLAUDE_SKILL_DIR}/references/graphql-patterns.md` - Complete GraphQL reference
## Companion Skills
| Skill | Plugin | When to Load |
|-------|--------|--------------|
| **content-modeling** | craft-developer | Content architecture planning, Matrix strategy |
| **craft-5-migration** | craft-developer | Breaking changes, upgrade patterns |
| **craft-mcp** | craft-developer | Direct database/schema inspection via MCP |
| **livewires** | live-wires | Frontend CSS when building Craft templates |
## Ecosystem Integration
Official and third-party Claude Code plugins that complement this skill:
| Plugin | Tool | When to Use |
|--------|------|-------------|
| **context7** | `/context7` | Live documentation for Craft CMS, Twig, Yii2 |
| **playwright** | Browser tools | Visual testing of rendered Craft templates |
| **superpowers** | `/debug` | Debug Craft template and query issues |
| **figma** | Design context tools | Extract design specs when building Craft templates |
## Community Support Tips
When helping in Discord/forums:
1. Ask for Craft version (4 vs 5 matters)
2. Request the actual Twig/PHP code, not description
3. Check if Matrix vs regular entries confusion
4. Suggest `.dd()` or `{% dd %}` for debugging
5. Link to specific docs section, not just "check docs"