All skills
michtio avatar

/craft-content-modeling

@e594ffc

Craft CMS 5 content modeling — sections, entry types, fields, Matrix, relations, project config, and content architecture strategy. Covers choosing section types, designing entry types and field layouts, selecting field types, configuring Matrix and nested entries, relations and eager loading, and multi-site propagation. Triggers on: section types (single, channel, structure), entry types, field types, field layout design, field type selection, Matrix, nested entries, relatedTo, eager loading, .with()/.eagerly(), categories, tags, globals, global sets, preloadSingles, propagation, multi-site content, project config, YAML, content strategy, taxonomy, asset volumes, filesystems, image transforms, user groups, content permissions, entrify/entrification, CKEditor vs Matrix, CMS editions, multi-language, language groups, localization, translation method, field translation, content migration, field instances, Formie forms as elements vs project config, cross-environment Formie deployment, multi-site Formie translation, element index sources, elementSources, Entries index sidebar, tableAttributes, defaultSort, authoring schema from code (service layer vs project.yaml + pc/apply). Always use when planning content architecture, creating sections/fields, configuring Matrix, setting up relations, choosing field types, designing field layouts, planning multi-site propagation, or tidying the Entries element index. Do NOT trigger for PHP plugin/module development, custom field type code, front-end Twig, or buildchain.

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

This session only. Nothing lands on disk.

referencesinfrastructure.md

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

Infrastructure — Propagation, Project Config, Storage, Assets

Multi-site propagation, project config workflow, how Craft stores content internally, and asset volume/filesystem/transform architecture.

Contents

  • Propagation Methods (Multi-Site) — section/Matrix propagation, field translation methods
  • Project Config Essentials — YAML workflow, deployment rules, dateModified
  • How Craft Stores Content — five-table model, JSON field values, relations storage, nested sets, drafts
  • Asset Volumes, Filesystems, and Transforms — three-layer model, filesystem architecture, volume settings, image transforms

Common Pitfalls

  • Not configuring propagation and translation methods before populating content — changing later resaves all entries.
  • Manually editing project config YAML — let Craft manage config/project/.
  • Forgetting project-config/touch after non-CP changes to YAML (Git pull, manual edit, merge conflict resolution).
  • Using database IDs in URI formats — IDs differ across environments. Use {slug} or {canonicalUid}.
  • Not setting allowAdminChanges => false in production.

Propagation Methods (Multi-Site)

Available for channels and structures. Singles always propagate to all sites.

Method Behavior
Only save to site created in Entries exist in one site only
Same site group Entries propagate within the same site group
Same language Entries propagate to sites sharing the same language
All enabled sites Entries exist in all enabled sites (default)
Let each entry choose Per-entry control via Status sidebar

Matrix fields have their own propagation method, independent of the section's.

Address elements do NOT propagate with their owner. Despite implementing NestedElementInterface via NestedElementTrait, Address elements don't override getSupportedSites() or isLocalized(). They fall through to the base Element default: primary site only. Matrix entries get multi-site behavior because the Entry class delegates to the field's propagation config — this is Entry-specific, not trait behavior. When working with Addresses on a multi-site project, the address exists only on the primary site regardless of which sites the owner (User, Entry) propagates to.

Field Translation Methods

Per-field setting controlling how values behave across sites:

Method Behavior
Not translatable Same value across all sites
Per site Independent value per site
Per site group Shared within site group, independent across groups
Per language Shared across sites with same language
Custom User-defined grouping key

Configure translation methods before populating content.

Project Config Essentials

All schema changes (sections, entry types, fields, volumes, transforms, sites, plugins, permissions) are stored as YAML in config/project/.

Workflow

  1. Make CP changes in development environment
  2. YAML auto-updates in config/project/
  3. Commit to Git
  4. Deploy to staging/production
  5. Run ddev craft up (applies migrations + project config)

Rules

  • Never manually edit YAML — let Craft manage it
  • Always set allowAdminChanges => false in production
  • Use UIDs (not IDs) — they're stable across environments
  • After resolving Git merge conflicts in YAML: ddev craft project-config/touch then ddev craft project-config/apply
  • Use $ENV_VAR syntax in YAML for environment-specific values — including for genuinely per-environment plugin settings; don't move a setting to a DB-only column to get "per-environment tuning"
  • Plugin settings — including per-instance/per-entity operational settings (alert thresholds, notification routing, workflow mappings) — are configuration and belong in project config, the canonical source of truth, not a DB-only column. Set them locally and deploy; prod project config is read-only by design. The risk to manage is YAML↔DB divergence (a downstream DB edit silently reverts on the next craft up), fixed by keeping project config authoritative — not by bypassing it. For the full rationale and the code-review misconception it corrects, see the craftcms skill's architecture.md → "Settings belong in project config".
  • After every project config change (whether editing project.yaml or any subfile in config/project/): run ddev craft project-config/touch to update the dateModified timestamp, then ddev craft up to apply. The CP auto-updates dateModified when changes are saved through the UI, but any change made outside the CP (Git pull, manual edit, merge conflict resolution, script) requires project-config/touch to signal that config has changed. Without it, craft up on other environments won't detect the change. This is a hard rule — never skip it.
  • After creating sections or singles that appear in Entries: tidy elementSources.craft\elements\Entry (placement under the right heading, tableAttributes / defaultSort matched to role peers). This is part of the content model, not optional CP cosmetics. Full checklist: element-index-sources.md.

Authoring schema from code (migrations, scripts, MCP tools)

When code creates schema — a content migration, a setup script, an AI/MCP tool — author it through the service layer: Craft::$app->getEntries()->saveSection(), ->saveEntryType(), Craft::$app->getFields()->saveField(), and the field-layout APIs (see the craftcms skill's migrations.md → "Content Migrations"). Craft validates the model (you get structured errors back), assigns/reuses UIDs, and writes the project-config YAML for you — so "author through Craft" and "YAML is the source of truth" are the same thing, not a trade-off.

Do not have code emit raw config/project/*.yaml and lean on project-config/apply to author new schema. Hand-composing YAML means hand-managing UIDs, key ordering, and nested structure; validation only happens at apply time, as an all-or-nothing diff that fails opaquely. project-config/apply is for propagating already-committed YAML across environments (the deploy step), not for authoring.

This also decides where a change is even possible: production runs allowAdminChanges => false, which makes Craft refuse runtime project-config writes. So service-layer schema writes only work where allowAdminChanges is true (typically local/dev). The correct lifecycle is: author in dev via the service layer → commit the YAML Craft wrote → deploy → craft up applies it. An agent hand-writing YAML on a locked-down prod box and running apply is effectively an out-of-band deploy that drifts from Git — avoid it.

Note the boundary: this is about schema (project config). Content (entries, assets — "create a blog post") is database state written with saveElement() and never touches project config, regardless of allowAdminChanges.

How Craft Stores Content

Understanding the storage architecture helps make better content modeling decisions.

The Five-Table Model

Every element writes to multiple tables on save:

  1. elements — Identity registry. Stores ID, element type, enabled/archived/soft-deleted flags, timestamps. Does NOT store content.
  2. elements_sites — Per-site state. Stores URI, slug, per-site enabled status, and the content JSON column where all custom field values live.
  3. Element-type table (entries, assets, users, etc.) — Type-specific attributes. For entries: section, type, author, postDate, expiryDate.
  4. relations — Normalized source-to-target connections for relational fields. Enables bidirectional relatedTo() queries.
  5. searchindex — Denormalized text projection for full-text search. Rebuildable from canonical data.

Field Values Are JSON Keyed by Instance UID

Custom field values are stored in elements_sites.content as JSON, keyed by the field layout element UID (not the field handle). This is why field instances work — the same field definition can appear multiple times in a layout with different handles, and each stores its data independently under its own instance UID.

This matters when:

  • Debugging content in the database — look up the field layout element UID, not the handle
  • Writing migrations that move field data — set values through the element API (setFieldValue() + saveElement()), which resolves the handle to the right instance UID and writes content and relations; don't hand-edit the JSON or relations table by UID (see the craftcms skill's elements.md → "Field value storage")
  • Understanding why renaming a field handle doesn't require a migration

Relations: Two Storage Layers

Relational fields store data in two places:

  • JSON (elements_sites.content) — records which elements were selected and their order per field instance
  • relations table — normalized source-to-target connections that power relatedTo() queries in both directions

This dual storage is why relatedTo() queries are fast (indexed relations table) while the field value on an element reflects the authoring context (JSON).

Nested Entries (Matrix) Are Full Elements

Matrix entries are not stored in a separate content table. They are first-class elements with their own rows in elements, elements_sites, and entries. Parent linkage is in elements_owners. This means nested entries support relations, drafts, permissions, and search indexing — but also explains why deeply nested Matrix fields are expensive (many rows written per save, max_input_vars limits on large forms).

Structures Use Nested Sets

Structure sections store hierarchy in structureelements using a nested set model (left/right boundary values). This makes ancestry and descendant queries very fast without recursion, but reordering large structures requires recalculating boundary values across many rows.

Drafts Reuse Unchanged Nested Entries

When a draft is created, unchanged nested entries are not duplicated — they are reused from the canonical element. Only when a nested entry is modified in the draft does it get its own derivative element row. This minimizes storage overhead but means draft creation time scales with the number of modified blocks, not total blocks.

Asset Volumes, Filesystems, and Transforms

The Three-Layer Model

  1. Filesystem — the storage backend (local disk, S3, Google Cloud, Azure). Defined once with a handle, base URL, and settings. Plugins add cloud filesystems.
  2. Volume — the content layer. References a filesystem, adds a field layout for asset metadata, and controls permissions. This is what editors see.
  3. Image Transform — the processing layer. Named transforms (defined in Settings) or ad-hoc transforms (defined in templates). Can be stored on a separate filesystem from originals.

Filesystem Architecture Decisions

Approach When to use
One filesystem, one volume Simple projects. Local dev with a single uploads directory.
One filesystem, multiple volumes When you want separate content buckets (Images, Documents, Videos) but all stored in the same S3 bucket. Each volume uses a unique subpath (e.g., images/, documents/).
Multiple filesystems, multiple volumes When storage requirements differ — images on a CDN-optimized filesystem, documents on standard storage, private files on a non-public filesystem.
Separate transform filesystem When you want transforms on a CDN or different storage tier. Set transformFs on the volume — originals stay on the primary filesystem.

Subpath rules: When volumes share a filesystem, each must have a unique first-level subpath directory. If Volume A uses images/, Volume B cannot use images/ or images/photos/ — but documents/ is fine.

Environment variables: Filesystem handles and subpaths support $ENV_VAR syntax via App::parseEnv(), so storage can differ between local/staging/production.

Never use @web for filesystem URLs. @web is auto-detected from the HTTP request's Host header — it can be spoofed if trustedHostPatterns isn't configured, and it resolves to empty in console/queue contexts (no HTTP request). Use environment variables instead:

# Wrong — @web is unreliable
fs:
  localImages:
    url: '@web/uploads'
    settings:
      path: '@webroot/uploads'

# Correct — explicit env vars
fs:
  localImages:
    url: '$ASSETS_URL'
    settings:
      path: '$ASSETS_PATH'
# .env
ASSETS_URL=https://mysite.ddev.site/uploads
ASSETS_PATH=/var/www/html/web/uploads

@webroot for the path is less dangerous (filesystem path, not URL) but env vars are still preferred for portability. The URL is the critical one — it generates <img src> and download links, so a wrong value breaks every asset on the site.

Volume Settings for Content Modeling

Each volume has its own field layout — this is where you add custom metadata fields for assets. Common patterns:

  • Alternative Text (native field layout element) — always include for accessibility. Has its own translation method (altTranslationMethod) for multi-site. Since Craft 5.11.0 that method is honoured from the first save: before 5.11 the first alt text entered was copied to every site regardless of the setting, and only edits from the second save on were scoped (craftcms/cms#19067). The 5.11 migration backfills assets_sites.alt from the old global assets.alt column, which is now ignored and is removed in Craft 6 — read alt through the element (asset.alt, alt in GraphQL), never by joining assets.alt in a query.
  • Title translation — titleTranslationMethod controls whether asset titles differ per site (default: per site).
  • Custom fields — photographer credit, copyright notice, focal point, usage rights, expiry date.
  • Reserved handles on volumes: alt, extension, filename, folder, height, kind, size, volume, width.

File type restrictions are set on the Assets field (per usage), not on the volume itself. The same volume can serve images in one field and documents in another.

Modeling Decisions

Question Guidance
How many volumes? One per logical content bucket. Separate images from documents from private files.
How many filesystems? One per storage requirement. Dev uses local, production uses S3 — same volume handle, different filesystem per environment.
Should transforms use a separate filesystem? Yes if you want transforms on a CDN or cheaper storage. Otherwise the primary filesystem works fine.
Do volumes need different field layouts? Yes — photos need credit/copyright fields, documents need version/category fields.
How to handle multi-site asset metadata? Set altTranslationMethod and titleTranslationMethod per volume. Most projects use "per site" for alt text (accessibility translations) and "not translatable" for filenames.

Image Transforms

{# Named transform (defined in Settings -> Assets -> Image Transforms) #}
<img src="{{ asset.getUrl('thumb') }}" alt="{{ asset.alt }}">

{# Ad-hoc transform #}
{% set transform = { width: 300, height: 200, mode: 'crop', format: 'webp' } %}
<img src="{{ asset.getUrl(transform) }}" alt="{{ asset.alt }}">

{# Srcset #}
{{ asset.getImg({ width: 300 }, ['1.5x', '2x', '3x']) }}

Transform modes: crop (default), fit, stretch, letterbox. Formats: jpg, png, webp, avif. The letterbox mode supports a fill color (4.4.0+). upscale control (4.4.0+) prevents small images from being enlarged.

Named vs ad-hoc: Use named transforms for consistent sizes reused across templates (hero, thumbnail, card). Use ad-hoc transforms for one-off sizes or when transforms need to be dynamic. Named transforms are stored in project config and sync across environments.

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The skill provides comprehensive guidelines for content modeling in Craft CMS 5, including sections, fields, and project configuration. No malicious patterns, obfuscation, or unauthorized data exfiltration were detected. A low-severity finding is included regarding the potential for indirect prompt injection, as the skill instructs the agent to read local project configuration files which could be manipulated in a supply-chain attack to influence the agent's behavior.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at e594ffc. 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 3 months ago

README badge

README badge for michtio/craftcms-claude-skills/craft-content-modeling