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
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/craftcms-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
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
.envandconfig/general.php(useApp::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 comparisonsql-ops— index strategy behind sloworderBy/relation queriesnginx-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.
Reviews (0)
No reviews yet.
No comments yet.