Claude Skill

craftcms-ops

Craft CMS 5 development - content modeling, Twig templating, element queries, GraphQL, plugins, and the Craft 4-to-5 Matrix-as-entries change. Use for: craft cms, craftcms, craft 5, twig, pixel & tonic, matrix field, entry types, sections, element query, eager loading, blitz, pro

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_craftcms-ops-3dfaf0b.zip · 9 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/craftcms-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git git clone https://github.com/0xDarkMatter/claude-mods.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Craft CMS Operations

Facts verified as of 2026-07.

Authoritative reference for Craft CMS 5.x development: content modeling, Twig templating, element-query optimization, GraphQL/headless setups, plugin development, and the Craft 4 → 5 migration. Craft is a self-hosted PHP application built on Yii 2, backed by MySQL or PostgreSQL.

Version note (verified against craftcms.com/docs/5.x, 2026-06): Craft 5 is current; Craft 6 exists. The defining Craft 5 change is that Matrix is now an entries-based field — Matrix "blocks" are gone, replaced by nested entries with entry types. Fields are globally reusable across all field layouts. Don't ship Craft 3/4 "Matrix block" guidance.


Craft 5 architecture at a glance

Concept What it is Craft 5 change
Section Container exposing entry types + URL rules Three kinds: Single, Channel, Structure
Entry Type Atomic unit of content (fields, title, slug) Now global + reusable across sections, with per-section aliases
Entry An instance of an entry type Can be top-level or nested (inside Matrix/CKEditor)
Field Reusable input attached via field layouts Globally reusable — no per-field-instance duplication
Matrix field Repeatable nested content Now stores entries (entry types), not "blocks". Nesting supported natively
Project Config Version-controlled schema (config/project/) Source of truth for sections/fields/settings

Section types

Type Use for Has URLs? Hierarchy?
Single One-off pages (home, about) Optional fixed URI No
Channel Streams (blog, news, products) Yes, per-entry-type URI format No
Structure Nested/ordered content (docs, nav) Yes Yes (drag-to-order, levels)

Element queries (the 80/20)

Everything readable in Craft is an element (entries, assets, users, categories, tags). You fetch them with element queries.

{# Channel entries, newest first #}
{% set posts = craft.entries()
  .section('blog')
  .type('article')
  .orderBy('postDate DESC')
  .limit(10)
  .all() %}

{# Eager-load relations to kill N+1 #}
{% set posts = craft.entries()
  .section('blog')
  .with(['author', 'featuredImage', 'categories'])
  .all() %}

{# Single entry by slug #}
{% set page = craft.entries().section('pages').slug('about').one() %}

{# Relations: entries related to a given category #}
{% set related = craft.entries().relatedTo(category).all() %}
Need Method
Filter by section .section('handle')
Filter by entry type .type('handle')
Eager-load relations .with(['field', 'field.subfield'])
Status .status('live') / .status(['live','expired'])
One vs many .one() / .all() / .count() / .exists()
Pagination {% paginate query as pageInfo, entries %}
Eager-load nested Matrix entries .with(['matrixField']) then loop nested entries

Eager-loading nested entries (Craft 5): because Matrix content is now entries, eager-load the Matrix field then iterate the nested entries by their entry type:

{% set page = craft.entries().section('pages').with(['body']).one() %}
{% for block in page.body.all() %}
  {% switch block.type.handle %}
    {% case 'text' %}{{ block.richText }}
    {% case 'image' %}{{ block.image.one().url }}
  {% endswitch %}
{% endfor %}

See references/twig-and-queries.md for the full query parameter catalog, pagination, and Twig patterns.


Twig conventions

Pattern Rule
Private templates Prefix with _ (_layouts/, _partials/) so they're not directly routable
Layout inheritance {% extends '_layouts/base' %} + {% block content %}
Reusable markup {% include '_partials/card' with { entry: entry } %} or {{ include() }}
Avoid logic in templates Push business logic to a module/plugin service, not Twig
Caching {% cache %} — only after queries are optimized, never to mask N+1

Headless / GraphQL

Craft ships a GraphQL API for decoupled frontends (Next.js, Nuxt, Astro, etc.).

Concern Approach
Schema Define GraphQL schemas + scopes in Control Panel; generate a token per schema
Auth Bearer token per schema; public schema for anonymous reads
Alternative Element API plugin for custom JSON endpoints when GraphQL is overkill
CORS Configure allowed origins for the headless frontend
Eager loading GraphQL resolves relations efficiently; still design queries to avoid over-fetching

See references/graphql-and-plugins.md for schema setup, query shape, and plugin/module development.


Performance decision table

Symptom Fix
Slow listing pages Eager-load with .with([...]) — the #1 Craft perf bug is N+1 inside loops
Repeated identical render {% cache %} tag (after query optimization)
Whole-site cache needed Blitz plugin (static page caching, granular invalidation)
Slow orderBy on custom field Ensure the underlying column/field is indexed
Heavy asset transforms Pre-generate transforms; use Imgix/CDN

Project Config & deployment

  • Project Config (config/project/*.yaml) is the version-controlled source of truth for sections, fields, entry types, settings. Commit it.
  • Apply on deploy: php craft up (runs migrations + applies project config).
  • Environment-specific values go in .env and config/general.php (use App::env() / getenv()).
  • Data transformations belong in content migrations, not manual DB edits.

Craft 4 → 5 upgrade checklist

Area What changed Action
Matrix Blocks → entries with entry types Templates iterating .type.handle mostly survive; re-check block-type field handles
Fields Now globally reusable Expect field/entry-type proliferation post-upgrade — consolidate duplicates
Content storage Reworked internal storage Run php craft up; test queries on staging
PHP/DB Craft 5 needs PHP 8.2+ Verify host before upgrading
Plugins Many need a Craft 5-compatible release Audit plugin compatibility first

Full upgrade guidance: https://craftcms.com/docs/5.x/upgrade.html


Common gotchas

Gotcha Why Fix
N+1 queries in loops Element relations lazy-load Always .with([...]) before iterating
{% cache %} masking slow queries Cache hides, doesn't fix Optimize queries first, cache second
Business logic in Twig Hard to test/reuse Move to a module/plugin service
Project Config drift in teams Out-of-band CP edits Treat config/project/ as source of truth; php craft up on deploy
Untested migrations to prod Data loss risk Test on staging clone first
Over-using Matrix Complexity + perf cost Use simpler structures when nesting isn't needed
Calling old "Matrix block" APIs Removed in Craft 5 Use entry/entry-type APIs

Assets

File Use
assets/entry-type-field-layout.md Annotated content-modeling starter: section + entry type + field layout + Matrix-as-entries shape, mapped to Project Config

See also

  • laravel-ops — shared PHP/Composer/Twig-adjacent tooling, Eloquent patterns for comparison
  • sql-ops — index strategy behind slow orderBy/relation queries
  • nginx-ops — serving Craft, caching headers, reverse proxy for headless

Key external resources

Files (claude-mods)
  • assets
    • entry-type-field-layout.md 2.6 KB
      # Content-modeling starter — Craft 5 (section + entry type + Matrix-as-entries)
      
      A known-good shape for modeling a "flexible content page" in Craft 5. Adapt the handles.
      Craft 5 stores this in **Project Config** (`config/project/*.yaml`) — build it in the
      Control Panel, then commit the generated YAML. This file documents the *intended shape*;
      it is not itself applied.
      
      ## Target structure
      
      ```
      Section: "Pages"  (type: Structure — nestable, ordered)
      └── Entry Type: "page"
          ├── Field: title            (built-in)
          ├── Field: heading          (Plain Text, global, reusable)
          ├── Field: seoDescription   (Plain Text)
          └── Field: body             (Matrix — Craft 5: stores NESTED ENTRIES)
              ├── Entry Type: "richText"   → field: text   (CKEditor)
              ├── Entry Type: "imageBlock" → field: image  (Assets, limit 1)
              └── Entry Type: "callout"    → field: body   (Plain Text), style (Dropdown)
      ```
      
      Key Craft 5 facts baked into this shape:
      
      - **Matrix `body` holds entries, not "blocks".** Each nested entry has an **entry type**
        (`richText`, `imageBlock`, `callout`). Branch on `block.type.handle` in Twig.
      - **Fields are global.** `heading`, `text`, `image` etc. are defined once and reused across
        any field layout. Reuse the same `text` field in multiple entry types rather than cloning.
      - An entry type can be **shared across sections** with a per-section alias (name/handle
        override) if you want the same shape exposed in, say, both "Pages" and "Landing Pages".
      
      ## Rendering it (template `_layouts/page.twig` + section template)
      
      ```twig
      {% set page = craft.entries().section('pages').slug(craft.app.request.segment(1)).with(['body']).one() %}
      {% if not page %}{% exit 404 %}{% endif %}
      
      <h1>{{ page.heading ?: page.title }}</h1>
      
      {% for block in page.body.all() %}
        {% switch block.type.handle %}
          {% case 'richText' %}
            <div class="prose">{{ block.text }}</div>
          {% case 'imageBlock' %}
            {% set img = block.image.one() %}
            {% if img %}<figure><img src="{{ img.url }}" alt="{{ img.alt }}"></figure>{% endif %}
          {% case 'callout' %}
            <aside class="callout callout--{{ block.style.value }}">{{ block.body }}</aside>
        {% endswitch %}
      {% endfor %}
      ```
      
      ## Project Config notes
      
      - After creating the above in the CP, the schema lands in `config/project/` as YAML.
        Commit it. On deploy, `php craft up` applies it.
      - Don't hand-edit Project Config YAML for structural changes — make them in the CP and let
        Craft serialize, to keep UIDs consistent.
      - Environment-specific values (asset base URLs, API keys) belong in `.env` /
        `config/general.php`, never in Project Config.
      
  • references
    • graphql-and-plugins.md 3.5 KB
      # GraphQL, Headless & Plugin Development (Craft 5)
      
      Load this for decoupled/headless setups or when building a plugin/module.
      
      ## GraphQL / headless
      
      Craft ships a first-party GraphQL API. Flow:
      
      1. **Define a schema** in the Control Panel (GraphQL → Schemas). Scope it to the sections, entry types, asset volumes, etc. the frontend may read.
      2. **Generate a token** per schema. The **public schema** serves anonymous requests; private schemas require a Bearer token.
      3. **Endpoint**: `/api` by default (configurable). POST GraphQL queries; auth via `Authorization: Bearer <token>`.
      4. **CORS**: set allowed origins for the headless frontend (Next.js/Nuxt/Astro).
      
      Example query against entries (note the Craft 5 entry/entry-type model):
      
      ```graphql
      query Posts {
        entries(section: "blog", limit: 10, orderBy: "postDate DESC") {
          title
          slug
          ... on blog_article_Entry {
            postDate
            featuredImage { url }
            author { fullName }
          }
        }
      }
      ```
      
      The fragment type name follows `{section}_{entryType}_Entry`. Nested Matrix entries resolve as their own entry types under the Matrix field.
      
      ### When NOT to use GraphQL
      
      - Small number of fixed endpoints → the **Element API** plugin (custom JSON routes) is simpler.
      - Server-rendered Twig site → no API layer needed at all.
      
      ## Plugin vs module
      
      | Build a… | When |
      |----------|------|
      | **Module** | Project-specific code, no distribution (`modules/` in the app) |
      | **Plugin** | Reusable/distributable via the Plugin Store (Composer package) |
      
      Both extend Craft via Yii 2 components: services, controllers, element types, field types, widgets, behaviors, events.
      
      ## Plugin anatomy
      
      ```
      my-plugin/
      ├── composer.json          # type: craft-plugin, autoload PSR-4
      ├── src/
      │   ├── Plugin.php         # init(), registers services & event handlers
      │   ├── services/          # business logic (injectable)
      │   ├── controllers/       # CP + site request handlers
      │   ├── elements/          # custom element types
      │   ├── fields/            # custom field types
      │   └── migrations/        # install + content migrations
      └── README.md
      ```
      
      Key registration patterns in `Plugin::init()`:
      
      ```php
      // Register a service
      $this->setComponents(['myService' => MyService::class]);
      
      // Hook an event
      Event::on(
          Entries::class,
          Entries::EVENT_AFTER_SAVE_ENTRY,
          function (EntryEvent $e) { /* ... */ }
      );
      
      // Register a Twig extension
      Craft::$app->view->registerTwigExtension(new MyTwigExtension());
      ```
      
      Follow the official [coding guidelines](https://craftcms.com/docs/5.x/extend/coding-guidelines.html) — namespacing, service-layer separation, and event-driven extension are expected idioms.
      
      ## Migrations
      
      | Migration kind | Purpose |
      |----------------|---------|
      | **Install migration** | Schema a plugin needs on install (`migrations/Install.php`) |
      | **Plugin migration** | Schema changes between plugin versions |
      | **Content migration** | Project data transformations (`php craft migrate/create`) — version-controlled, run via `php craft up` |
      
      Always test migrations on a staging clone before production.
      
      ## Integration points
      
      | Concern | Common choices |
      |---------|----------------|
      | Frontend frameworks | Next.js, Nuxt, Astro, Gatsby via GraphQL / Element API |
      | Hosting | Servd, Fortrabbit, Laravel Forge; DDEV for local |
      | Assets | AWS S3, Google Cloud Storage, Imgix transforms |
      | Search | Algolia, Elasticsearch via plugins |
      | Commerce | Craft Commerce |
      | Caching | Blitz (static pages), Redis, Cloudflare |
      
    • twig-and-queries.md 4.4 KB
      # Twig & Element Queries — deep dive (Craft 5)
      
      Load this when writing non-trivial templates, debugging N+1, or building pagination.
      
      ## Element query parameter catalog
      
      Every element type (entries, assets, users, categories, tags) shares a query builder. Common parameters for `craft.entries()`:
      
      | Parameter | Purpose | Example |
      |-----------|---------|---------|
      | `.section()` | One or more section handles | `.section(['blog','news'])` |
      | `.type()` | Entry type handle | `.type('article')` |
      | `.id()` | Specific element id(s) | `.id(42)` |
      | `.slug()` | By slug | `.slug('about')` |
      | `.status()` | `live`, `pending`, `expired`, `disabled` | `.status(['live','expired'])` |
      | `.orderBy()` | Sort | `.orderBy('postDate DESC')` |
      | `.limit()` / `.offset()` | Slice | `.limit(10).offset(20)` |
      | `.search()` | Full-text via search index | `.search('keyword')` |
      | `.relatedTo()` | Relationship queries | `.relatedTo(category)` |
      | `.with()` | Eager-load relations | `.with(['author','image'])` |
      | `.site()` | Target a specific site (multi-site) | `.site('en')` |
      | `.unique()` | Dedupe across sites | `.unique()` |
      
      Terminators: `.all()`, `.one()`, `.count()`, `.exists()`, `.ids()`, `.nth(n)`, `.collect()` (returns a Collection).
      
      ## Eager loading (kill N+1)
      
      The single most common Craft performance bug is querying relations inside a loop. Always eager-load:
      
      ```twig
      {# BAD — one query per entry for author #}
      {% for entry in craft.entries().section('blog').all() %}
        {{ entry.author.one().fullName }}
      {% endfor %}
      
      {# GOOD — eager-load up front #}
      {% set posts = craft.entries().section('blog').with(['author']).all() %}
      {% for entry in posts %}
        {{ entry.author.fullName }}
      {% endfor %}
      ```
      
      Nested paths work: `.with(['author.userPhoto', 'categories', 'body'])`.
      
      ### Eager-loading nested Matrix entries (Craft 5)
      
      Matrix content is now nested *entries*. Eager-load the Matrix field, then branch on `block.type.handle`:
      
      ```twig
      {% set page = craft.entries().section('pages').slug(slug).with(['body']).one() %}
      {% for block in page.body.all() %}
        {% switch block.type.handle %}
          {% case 'richText' %}
            {{ block.text }}
          {% case 'imageBlock' %}
            {% set img = block.image.one() %}
            {% if img %}<img src="{{ img.url }}" alt="{{ img.alt }}">{% endif %}
          {% case 'callout' %}
            <aside>{{ block.body }}</aside>
        {% endswitch %}
      {% endfor %}
      ```
      
      To eager-load relations *inside* nested entries, use a nested path through the Matrix handle (e.g. `.with(['body.image'])`).
      
      ## Pagination
      
      ```twig
      {% set query = craft.entries().section('blog').orderBy('postDate DESC') %}
      {% paginate query.limit(12) as pageInfo, entries %}
      
      {% for entry in entries %}
        {{ entry.title }}
      {% endfor %}
      
      {% if pageInfo.prevUrl %}<a href="{{ pageInfo.prevUrl }}">Previous</a>{% endif %}
      {% if pageInfo.nextUrl %}<a href="{{ pageInfo.nextUrl }}">Next</a>{% endif %}
      ```
      
      `pageInfo` exposes `.currentPage`, `.totalPages`, `.total`, `.first`, `.last`, `.getRangeUrls()`.
      
      ## Template organization
      
      | Convention | Detail |
      |------------|--------|
      | Private templates | Prefix `_` (`_layouts/`, `_partials/`, `_macros/`) so Craft won't route to them directly |
      | Layout inheritance | `{% extends '_layouts/base' %}`, fill `{% block %}` regions |
      | Includes | `{% include '_partials/card' with { entry } only %}` — `only` isolates scope |
      | Macros | `{% macro %}` / `{% import %}` for repeated markup helpers |
      | Embeds | `{% embed %}` when you need to override blocks inside an included template |
      
      ## Caching
      
      ```twig
      {% cache %}
        {# expensive, rarely-changing markup #}
      {% endcache %}
      
      {% cache unless craft.app.config.general.devMode %}...{% endcache %}
      {% cache for 1 week %}...{% endcache %}
      {% cache using key entry.id %}...{% endcache %}
      ```
      
      Rules:
      - Cache *after* eager-loading and query optimization, never instead of it.
      - `{% cache %}` does not cache the query result tags it wraps if they contain `{% nocache %}` regions.
      - For full static-page caching with smart invalidation, reach for **Blitz** rather than hand-rolled `{% cache %}`.
      
      ## Multi-site
      
      | Need | Approach |
      |------|----------|
      | Query a specific site | `.site('handle')` |
      | Query all sites | `.site('*')` then `.unique()` to dedupe shared elements |
      | Current site in template | `craft.app.sites.currentSite` |
      | Localized URLs | `entry.url` resolves per-site; `craft.entries().id(x).site('fr').one().url` for the other locale |
      
      Content can be propagated or per-site editable per field/section setting; design the section's propagation method up front.
      
  • scripts
    • .gitkeep 0 B · in bundle
  • SKILL.md 9 KB
    ---
    name: craftcms-ops
    description: "Craft CMS 5 development - content modeling, Twig templating, element queries, GraphQL, plugins, and the Craft 4-to-5 Matrix-as-entries change. Use for: craft cms, craftcms, craft 5, twig, pixel & tonic, matrix field, entry types, sections, element query, eager loading, blitz, project config, headless craft, craft graphql, craft plugin, craft 4 to 5 upgrade."
    when_to_use: "Use when building on Craft CMS 5 — e.g. 'model content with Matrix-as-entries', 'write a Twig template or element query', 'set up headless Craft with GraphQL', 'migrate Craft 4 to 5'. Covers sections, entry types, eager loading, project config, and plugins."
    license: MIT
    allowed-tools: "Read Write Bash"
    metadata:
      author: claude-mods
      related-skills: laravel-ops, sql-ops, nginx-ops
    ---
    
    # Craft CMS Operations
    
    > Facts verified as of 2026-07.
    
    Authoritative reference for **Craft CMS 5.x** development: content modeling, Twig templating, element-query optimization, GraphQL/headless setups, plugin development, and the Craft 4 → 5 migration. Craft is a self-hosted PHP application built on Yii 2, backed by MySQL or PostgreSQL.
    
    > **Version note (verified against craftcms.com/docs/5.x, 2026-06):** Craft 5 is current; Craft 6 exists. The defining Craft 5 change is that **Matrix is now an entries-based field** — Matrix "blocks" are gone, replaced by nested **entries** with **entry types**. Fields are **globally reusable** across all field layouts. Don't ship Craft 3/4 "Matrix block" guidance.
    
    ---
    
    ## Craft 5 architecture at a glance
    
    | Concept | What it is | Craft 5 change |
    |---------|-----------|----------------|
    | **Section** | Container exposing entry types + URL rules | Three kinds: Single, Channel, Structure |
    | **Entry Type** | Atomic unit of content (fields, title, slug) | Now **global + reusable** across sections, with per-section aliases |
    | **Entry** | An instance of an entry type | Can be top-level or **nested** (inside Matrix/CKEditor) |
    | **Field** | Reusable input attached via field layouts | **Globally reusable** — no per-field-instance duplication |
    | **Matrix field** | Repeatable nested content | **Now stores entries** (entry types), not "blocks". Nesting supported natively |
    | **Project Config** | Version-controlled schema (`config/project/`) | Source of truth for sections/fields/settings |
    
    ### Section types
    
    | Type | Use for | Has URLs? | Hierarchy? |
    |------|---------|-----------|-----------|
    | **Single** | One-off pages (home, about) | Optional fixed URI | No |
    | **Channel** | Streams (blog, news, products) | Yes, per-entry-type URI format | No |
    | **Structure** | Nested/ordered content (docs, nav) | Yes | Yes (drag-to-order, levels) |
    
    ---
    
    ## Element queries (the 80/20)
    
    Everything readable in Craft is an *element* (entries, assets, users, categories, tags). You fetch them with element queries.
    
    ```twig
    {# Channel entries, newest first #}
    {% set posts = craft.entries()
      .section('blog')
      .type('article')
      .orderBy('postDate DESC')
      .limit(10)
      .all() %}
    
    {# Eager-load relations to kill N+1 #}
    {% set posts = craft.entries()
      .section('blog')
      .with(['author', 'featuredImage', 'categories'])
      .all() %}
    
    {# Single entry by slug #}
    {% set page = craft.entries().section('pages').slug('about').one() %}
    
    {# Relations: entries related to a given category #}
    {% set related = craft.entries().relatedTo(category).all() %}
    ```
    
    | Need | Method |
    |------|--------|
    | Filter by section | `.section('handle')` |
    | Filter by entry type | `.type('handle')` |
    | Eager-load relations | `.with(['field', 'field.subfield'])` |
    | Status | `.status('live')` / `.status(['live','expired'])` |
    | One vs many | `.one()` / `.all()` / `.count()` / `.exists()` |
    | Pagination | `{% paginate query as pageInfo, entries %}` |
    | Eager-load nested Matrix entries | `.with(['matrixField'])` then loop nested entries |
    
    **Eager-loading nested entries (Craft 5):** because Matrix content is now entries, eager-load the Matrix field then iterate the nested entries by their entry type:
    
    ```twig
    {% set page = craft.entries().section('pages').with(['body']).one() %}
    {% for block in page.body.all() %}
      {% switch block.type.handle %}
        {% case 'text' %}{{ block.richText }}
        {% case 'image' %}{{ block.image.one().url }}
      {% endswitch %}
    {% endfor %}
    ```
    
    See `references/twig-and-queries.md` for the full query parameter catalog, pagination, and Twig patterns.
    
    ---
    
    ## Twig conventions
    
    | Pattern | Rule |
    |---------|------|
    | Private templates | Prefix with `_` (`_layouts/`, `_partials/`) so they're not directly routable |
    | Layout inheritance | `{% extends '_layouts/base' %}` + `{% block content %}` |
    | Reusable markup | `{% include '_partials/card' with { entry: entry } %}` or `{{ include() }}` |
    | Avoid logic in templates | Push business logic to a module/plugin service, not Twig |
    | Caching | `{% cache %}` — **only after** queries are optimized, never to mask N+1 |
    
    ---
    
    ## Headless / GraphQL
    
    Craft ships a GraphQL API for decoupled frontends (Next.js, Nuxt, Astro, etc.).
    
    | Concern | Approach |
    |---------|----------|
    | Schema | Define **GraphQL schemas** + scopes in Control Panel; generate a token per schema |
    | Auth | Bearer token per schema; public schema for anonymous reads |
    | Alternative | Element API plugin for custom JSON endpoints when GraphQL is overkill |
    | CORS | Configure allowed origins for the headless frontend |
    | Eager loading | GraphQL resolves relations efficiently; still design queries to avoid over-fetching |
    
    See `references/graphql-and-plugins.md` for schema setup, query shape, and plugin/module development.
    
    ---
    
    ## Performance decision table
    
    | Symptom | Fix |
    |---------|-----|
    | Slow listing pages | Eager-load with `.with([...])` — the #1 Craft perf bug is N+1 inside loops |
    | Repeated identical render | `{% cache %}` tag (after query optimization) |
    | Whole-site cache needed | **Blitz** plugin (static page caching, granular invalidation) |
    | Slow `orderBy` on custom field | Ensure the underlying column/field is indexed |
    | Heavy asset transforms | Pre-generate transforms; use Imgix/CDN |
    
    ---
    
    ## Project Config & deployment
    
    - **Project Config** (`config/project/*.yaml`) is the version-controlled source of truth for sections, fields, entry types, settings. Commit it.
    - Apply on deploy: `php craft up` (runs migrations + applies project config).
    - Environment-specific values go in `.env` and `config/general.php` (use `App::env()` / `getenv()`).
    - Data transformations belong in **content migrations**, not manual DB edits.
    
    ---
    
    ## Craft 4 → 5 upgrade checklist
    
    | Area | What changed | Action |
    |------|--------------|--------|
    | Matrix | Blocks → **entries with entry types** | Templates iterating `.type.handle` mostly survive; re-check block-type field handles |
    | Fields | Now **globally reusable** | Expect field/entry-type proliferation post-upgrade — consolidate duplicates |
    | Content storage | Reworked internal storage | Run `php craft up`; test queries on staging |
    | PHP/DB | Craft 5 needs PHP 8.2+ | Verify host before upgrading |
    | Plugins | Many need a Craft 5-compatible release | Audit plugin compatibility first |
    
    Full upgrade guidance: <https://craftcms.com/docs/5.x/upgrade.html>
    
    ---
    
    ## Common gotchas
    
    | Gotcha | Why | Fix |
    |--------|-----|-----|
    | N+1 queries in loops | Element relations lazy-load | Always `.with([...])` before iterating |
    | `{% cache %}` masking slow queries | Cache hides, doesn't fix | Optimize queries first, cache second |
    | Business logic in Twig | Hard to test/reuse | Move to a module/plugin service |
    | Project Config drift in teams | Out-of-band CP edits | Treat `config/project/` as source of truth; `php craft up` on deploy |
    | Untested migrations to prod | Data loss risk | Test on staging clone first |
    | Over-using Matrix | Complexity + perf cost | Use simpler structures when nesting isn't needed |
    | Calling old "Matrix block" APIs | Removed in Craft 5 | Use entry/entry-type APIs |
    
    ---
    
    ## Assets
    
    | File | Use |
    |------|-----|
    | `assets/entry-type-field-layout.md` | Annotated content-modeling starter: section + entry type + field layout + Matrix-as-entries shape, mapped to Project Config |
    
    ---
    
    ## See also
    
    - `laravel-ops` — shared PHP/Composer/Twig-adjacent tooling, Eloquent patterns for comparison
    - `sql-ops` — index strategy behind slow `orderBy`/relation queries
    - `nginx-ops` — serving Craft, caching headers, reverse proxy for headless
    
    ### Key external resources
    
    - [Craft CMS 5.x Docs](https://craftcms.com/docs/5.x/)
    - [Entries reference](https://craftcms.com/docs/5.x/reference/element-types/entries.html)
    - [Matrix fields (Craft 5)](https://craftcms.com/docs/5.x/reference/field-types/matrix.html)
    - [Eager-loading](https://craftcms.com/docs/5.x/development/eager-loading.html)
    - [GraphQL API](https://craftcms.com/docs/5.x/development/graphql.html)
    - [Upgrading from Craft 4](https://craftcms.com/docs/5.x/upgrade.html)
    - [Coding guidelines](https://craftcms.com/docs/5.x/extend/coding-guidelines.html)
    - [Blitz plugin](https://putyourlightson.com/plugins/blitz) · [nystudio107 blog](https://nystudio107.com/blog) · [Craft Stack Exchange](https://craftcms.stackexchange.com/)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related