Status: Draft
Updated: 2026-06-05 Owner: Core
Purpose: Draft for separating static pages, dynamic content, routing hierarchy, and project organization.
- Defines how static pages and dynamic content should coexist.
- Covers URL structure, hierarchy, routing ownership, and content organization.
- Provides context for schema-driven fields, editor workflows, and theme rendering.
- Establishes content as the central domain for routing, rendering, API access, search, and import/export.
The CMS should support both static project pages and dynamic structured content. Static pages are useful for predictable website sections such as legal pages, landing pages, documentation pages, or project profiles. Dynamic content is useful for repeatable content types such as articles, releases, references, team entries, resources, or module-provided domain records.
The first content model should avoid overfitting. It should define stable concepts for content identity, route identity, publication state, localization, hierarchy, dynamic schema rendering, and structured field data. More specialized content behavior can then be layered on top through schemas, editor workflows, base theme rendering, API output, search indexing, and import/export.
Database-backed content schemas are the main extension point for content types. The CMS should ship with complete preset schemas so users can create normal pages and repeatable content immediately, while still allowing site builders to define custom schemas, custom fieldsets, validation rules, localized fields, relationships, and optional inner Twig rendering when a project needs a bespoke model.
Variable fieldsets should also support structured relationships between content entities. A project may model lore, timelines, characters, memories, locations, products, documentation nodes, or any other domain as separate content entities and then connect them through schema fields. The model should allow direct 1:1, 1:n, n:1, and n:n relationships through reference fields and controlled query fields without requiring users to duplicate aggregated context by hand.
Variable fieldset assembly should be supported by indexed metadata where practical. The content and field tables remain the source of truth, but a lean database-backed resolver/reference index may help locate the relevant field values for an entity, schema, language, variant, or relationship quickly instead of forcing broad field-value scans during rendering.
This relationship model is especially important for LLM-assisted work. When content is exported or inspected for collaboration, the system should be able to navigate from the current content item to explicitly referenced entities, related field values, query results, and later resolver-generated suggestions. The goal is to let an assistant gather relevant context from the project graph instead of flattening everything into one large document.
Sanity is a useful broad reference for content workflow depth, mutation ergonomics, revision-aware collaboration, and rich API-assisted authoring, but Studio should not copy Sanity's model directly or drift into a headless-first product. The web editor remains the preferred interaction path for human authors. The API should expose enough stable, structured content operations to make LLM collaboration, imports, validation, diffs, and assisted editing pleasant, while the editor and API continue to share the same domain workflows instead of growing separate behavior.
Content schemas may define their own inner rendering for schema-driven fieldsets. The theme provides the surrounding page layout and a generic one-size-fits-all fieldset renderer, but a user-defined schema may store custom Twig markup in the database to replace or extend the inner content block that renders that schema's fields. This dynamic schema Twig is independent from theme template files and should affect only the content block's internal presentation, not the outer page layout, route handling, or theme asset selection.
Content revisions are also the review boundary for imports and editor changes. An import can stage proposed changes as new revisions, diff those revisions against the active revisions across all affected entities, and activate them only after explicit review. Reverting content can then switch the active revision pointer back to a previous revision without rewriting historical field values.
User-defined schema Twig should be validated before it can be saved or activated. The validation flow should use the shared error-handling model and return actionable feedback to the editor. The system may additionally define a safety dictionary for allowed and disallowed Twig functions, filters, tags, or variables. If validated custom Twig still produces a domain-specific rendering problem, responsibility belongs to the user who controls the schema, but the system should keep recovery paths such as version history, preview, and fallback rendering available.
Routing should remain explicit and inspectable. Symfony routes should handle administrative and system routes, while content routes should be resolved through a dedicated content route resolver. Content URLs should be unique, reviewable before publication, and protected from accidental conflicts with core routes, admin routes, API routes, assets, and module routes.
- Define a durable content identifier that is separate from the public URL.
- Store URL path or slug information as content routing metadata, not as the only content identity.
- Keep slugs unique within their routing scope so they do not interfere with hierarchy, variant, redirect, or lookup workflows.
- Restrict slugs to lowercase ASCII letters, numbers, and
-as the only separator. Slugs must not start or end with-, and other special characters are reserved for routing features. - Use the content UID as durable identity.
- Use the slug as the stable routing token for the content item.
- Allow an optional custom URL path as routing metadata when the default hierarchy-based path is not suitable.
- Mirror URL hierarchy from content hierarchy by default, for example
/slug-1/slug-2. - Support content versioning so changes can be reverted to a previous state without changing the durable UID.
- Support publication states such as
draft,scheduled,published, andarchivedonce editor workflows require them. - Track current/last lifecycle metadata such as created, modified, published, archived, deleted, restored, locked, and unlocked through reusable
state_markerrows. - Track visibility metadata such as public/private state and optional ACL restrictions when only specific ACL groups may view content.
- Track ownership or responsible user/group metadata when workflow, review, or ACL behavior needs it.
- Track editing lock metadata through
state_markeronly if concurrent editing protection is implemented. - Reserve route prefixes for system areas such as
admin/,editor/,setup/,cron/,system/,user/,api/,assets/,_profiler/,_wdt/,build/,packages/,media/, andfiles/. - Treat
/system/...as an internal content namespace. It may contain content entities used by resolvers, redirects, theme slots, and later custom error pages, but direct browser delivery through the public catch-all route must be blocked. - Use Symfony controllers for admin/content-management routes.
- Use a content route resolver for public content rendering.
- Treat database-backed schemas as user-defined content-type definitions, with shipped presets providing a complete default content model.
- Keep rendering separate from persistence:
- Content entities store identity, slug, status, parent, order, routing metadata, redirects, schema reference, version, language/variant availability, ACL restrictions, and other stable content metadata. Current/last lifecycle metadata lives in reusable state markers.
- Field values store their own UID, associated content UID, version, language, variant, field identifier, and field content.
- Index metadata may help locate the right field values for variable fieldset assembly, but it must not replace the field tables as the source of truth.
- Content services load and validate content.
- Theme services provide outer templates, assets, and generic fieldset rendering.
- Content schema rendering resolves optional database-stored Twig markup for the inner content block.
- Controllers coordinate request handling and response creation.
- Allow content schemas to store optional custom Twig markup for fieldset rendering.
- Allow users to define schema fields, field types, required flags, localization behavior, validation rules, and rendering metadata through the schema model.
- Provide a generic default renderer for schema-driven fieldsets when no custom schema Twig exists.
- Keep database-stored schema Twig scoped to the inner content block and the validated field values for that schema.
- Validate custom schema Twig before saving, publication, or activation to prevent broken public rendering.
- Consider a safety dictionary for allowed and disallowed Twig functions, filters, tags, or variables.
- Keep theme template files and dynamic schema Twig as separate rendering layers.
- Require every content schema to include the reserved base field identifiers
titleandsubtitlein its variable fieldset. - Store
titleandsubtitleas schema field values, not as content entity metadata or dedicated content columns. - Allow schema fields that filter and aggregate content, for example blog list views or project hubs, but execute them through controlled content-query services instead of arbitrary template logic.
- Allow schema fields that create explicit entity relationships, including 1:1, 1:n, n:1, and n:n content references where the selected field type supports them.
- Treat reference fields and controlled query fields as first-class inputs for context aggregation, search indexing, import/export, and LLM collaboration.
- Keep static pages and dynamic content in one conceptual content model unless implementation proves they need separate storage.
- Use Doctrine migrations for core content tables.
- Keep content hierarchy optional at first; do not require every content item to have a parent.
- Track redirects or previous slugs when URL changes become user-facing.
- Make localization and variants part of the model design even if the first implementation only ships one authoring flow.
- Keep language and generic variants separate concepts. Language uses configured leading locale prefixes such as
/de/slugwhen localized routing is enabled. - Resolve locale-prefixed routes before normal content paths only when the first path segment is an available translation language.
- Configure the public start page with
content.home_path, defaulting to/home. The root URL/and localized root URLs such as/derender that configured path internally, so content slugs never need to be empty or magical. - Control localized public routes through
localization.route_prefixes_enabled. When enabled, unprefixed public content routes redirect with302to the best matching browser language fromAccept-Languagewhen available, otherwise to the configured default language prefix, for example/aboutto/de/aboutand/to/de. - Derive available route language prefixes from available translation catalogues, and reserve them so users cannot create conflicting top-level content routes such as
/de. - Generic variants should prefer a separate reserved marker path segment for public pretty URLs, for example
/slug/~compact, because~is not allowed in slugs and the variant segment stays separate from the content slug. - Query parameters may remain useful as a fallback for preview/admin links or cases where a pretty variant URL is not desired, for example
/slug?variant=compact. - Missing generic variants should render the default variant when available and emit a warning message for logging or diagnostics.
- Missing language variants should render the default language when available, with a fallback to another available language variant when the default language is not available.
- Use
field_identifierto map stored field values back to their schema field definition. - Use content revisions as the unit for import previews, structured diffs, activation, revert, and retention cleanup.
- Prepare content serialization boundaries for API, import/export, search indexing, and LLM collaboration.
- Use Sanity only as a broad product reference for workflow completeness, not as an implementation contract. Content, editor, API, import/export, and LLM collaboration should share domain services so the web editor and API do not diverge.
The first implementation should keep routing, workflow, and permission fields explicit while leaving non-field metadata flexible. This avoids overfitting the entity while keeping the fields needed for routing, ACL, publishing, and querying easy to index. Presentation fields that editors manage directly, including title and subtitle, belong to the schema-driven fieldset.
| Field | Purpose |
|---|---|
uid |
Durable content identity independent from URL, slug, language, variant, or version. |
slug |
Scoped routing token using the strict slug rules. |
status |
Publication state such as draft, scheduled, published, or archived. |
parent_uid |
Required parent marker for hierarchy. Root-level content stores /; child content stores a content UID; system is reserved as the only virtual parent for internal /system/... entities. |
sort_order |
Optional ordering within the same parent scope. |
custom_url |
Optional route override when default hierarchy-based URL generation is not suitable. |
redirect_target |
Optional redirect target for moved or replaced content. Internal route targets render the target route without changing the browser URL; external http:// or https:// targets produce a temporary 302 redirect. |
schema_uid |
Applied fieldset schema used to validate and render field values. |
schema_version |
Applied schema version number for fast discovery and admin filtering. |
active_revision_uid |
Nullable active content revision. NULL disables normal rendering while preserving the entity for recovery or cleanup. |
version |
Current version marker for revert and history workflows. |
available_languages |
Languages available for this content item. |
available_variants |
Generic variants available for this content item. |
visibility |
Public/private visibility state. |
acl_restrictions |
Optional ACL groups allowed to view the content. |
view_min_level / view_group_identifiers |
Optional view override. NULL means inherit; otherwise level or listed group membership can grant access. |
edit_min_level / edit_group_identifiers |
Optional edit override. NULL means inherit. |
manage_min_level / manage_group_identifiers |
Optional manage override. NULL means inherit. |
metadata |
Flexible JSON payload for non-critical metadata such as SEO hints, OpenGraph data, ownership/responsibility labels, and content-type-specific flags. Reserved schema fields such as title and subtitle must not be stored here. |
title and subtitle are reserved base field identifiers. Every content schema must define them as required variable-fieldset fields so admin lists, menus, search previews, generic rendering, API output, import/export, and localization can consume the same schema-backed values.
| Field | Purpose |
|---|---|
uid |
Durable schema identity. |
identifier |
Unique content-type identifier such as static_page or blog_post. |
source |
Schema source such as preset, custom, or module. |
locked |
Marks immutable presets or package-owned schemas. |
active_version_uid |
Nullable active schema version. NULL disables normal use while preserving the schema for admin recovery or cleanup. |
labels / descriptions |
Translatable schema display text. |
metadata |
Non-critical schema metadata. |
| Field | Purpose |
|---|---|
uid |
Durable schema-version identity. |
schema_uid |
Parent schema. |
version |
Monotonic schema version number. |
title / description |
Translatable schema-version display text. |
definition |
JSON fieldset definition with fields, types, validation, localization, ordering, storage, and rendering metadata. |
custom_twig |
Optional database-backed inner Twig rendering template. |
definition_hash |
Stable hash for diffing, caching, and duplicate detection. |
use_min_level / use_group_identifiers |
Optional schema-use override. NULL means inherit from defaults. |
edit_min_level / edit_group_identifiers |
Optional schema-edit override. |
manage_min_level / manage_group_identifiers |
Optional schema-management override. |
| Field | Purpose |
|---|---|
uid |
Durable revision identity. |
content_uid |
Parent content entity. |
version |
Content revision number. |
schema_uid |
Schema used by this revision. |
schema_version_uid |
Exact schema version used to validate and render this revision. |
change_summary |
Optional editor/import summary. |
metadata |
Non-critical revision metadata. |
| Field | Purpose |
|---|---|
uid |
Durable field value identity. |
revision_uid |
Associated content revision UID. |
language |
Language context for localized values. |
variant |
Generic variant context for variant-specific values. |
field_identifier |
Stable schema field identifier used to map the value back to the field definition. |
field_content |
Stored field value or structured payload. |
- Test URL uniqueness and reserved-prefix conflicts.
- Test content route resolution separately from controller rendering.
- Test draft/unpublished content is not publicly rendered.
- Test ACL-restricted content is visible only to allowed ACL groups.
- Test state markers are updated by create, edit, publish, archive, delete, restore, lock, and unlock workflows.
- Test required base schema fields such as
titleandsubtitleappear in admin lists, generic rendering, search previews, or menus only where supported. - Test content metadata rejects reserved base schema field identifiers such as
titleandsubtitle. - Test internal
/system/...content can be resolved by services but remains blocked from direct public delivery. - Test redirect routes render internal targets without changing the browser URL, redirect external
http(s)targets with302, reject unsupported schemes, detect loops, and stop at a bounded hop count. - Test content identity remains stable when slugs or hierarchy change.
- Test version history can restore previous content state without changing the UID.
- Test persistence for static page-like and repeatable content examples.
- Test generic fieldset rendering when no custom schema Twig exists.
- Test custom database-stored schema Twig renders only the inner content block with validated field values.
- Test invalid schema Twig blocks publication or activation with actionable validation errors.
- Test safety dictionary behavior for allowed and disallowed Twig functions, filters, tags, or variables once implemented.
- Test content-query fields use controlled filters and do not run arbitrary query logic from Twig.
- Test slug validation rejects special characters, leading separators, trailing separators, and unsupported separators.
- Test locale-prefixed routes only activate for configured locale prefixes.
- Test localized routing redirects unprefixed routes to the browser-language/default prefix when enabled and leaves routes unchanged when disabled.
- Test translation-derived locale prefixes are reserved while localized routing is enabled.
- Test reserved-marker generic variant URL resolution and missing-variant fallback configuration.
- Test query-parameter generic variant fallback where supported.
- Render representative routes with
php bin/console render:route /<route>when templates exist. - Add search/import/export compatibility tests when those features consume content data.
- Test reference and query fields can represent common relationship shapes without duplicating target content.
- Decision recorded: Use one shared content concept for static pages and dynamic content, with schemas and rendering configuration defining behavior differences. Schemas may include fields that filter and aggregate content, but execution should happen through controlled content-query services and be detailed in the schema-driven fields draft.
- Decision recorded: Variable fieldsets may model structured relationships between content entities through reference fields and controlled query fields, including 1:1, 1:n, n:1, and n:n relationship shapes.
- Decision recorded: Variable fieldset assembly may use lean resolver/reference index metadata for efficient lookup, while content and field tables remain the source of truth.
- Decision recorded: Content relationships should support LLM-assisted context gathering by allowing exports and resolver tools to navigate from a current entity to related entities, fields, and query results.
- Decision recorded: Content schemas may store custom Twig markup in the database for rendering the inner content block of schema-driven fieldsets. This is independent from theme template files; the theme provides the outer layout and generic fallback renderer.
- Decision recorded: User-defined schema Twig must pass validation before it is saved or activated. A safety dictionary for allowed and disallowed Twig features should be considered.
- Decision recorded: Keep public URLs separate from durable content identity. Each content entry should have a UID, unique scoped slug, optional custom URL path, version history, and default hierarchy-based URL generation.
- Decision recorded: Reserve core prefixes early to avoid future URL conflicts. Initial reserved prefixes include
admin/,editor/,setup/,cron/,system/,user/,api/,assets/,_profiler/,_wdt/,build/,packages/,media/, andfiles/. - Decision recorded: Root-level content stores the deterministic parent marker
/instead ofNULL, so(parent_uid, slug)uniqueness remains enforceable across SQLite, PostgreSQL, and MariaDB/MySQL./system/...is an internal-only content namespace. It uses the virtual parent markersysteminstead of requiring a real root entity, so system content can be addressed by resolvers without being delivered directly by the public catch-all route. - Decision recorded: Custom error pages should be layered over theme defaults later. Error handlers should first try a matching internal entity such as
/system/error-pages/404; if it is missing or unavailable, the system theme should render its default error page. - Decision recorded: Content hierarchy should start with stable content metadata such as optional parent and order fields on the content entity, not inside variable fieldsets.
- Decision recorded: Content records should carry stable metadata such as UID, slug, status, parent, order, custom URL, redirect, applied fieldset schema, version, available languages and variants, ACL restrictions, and optional ownership/responsibility metadata. Current/last lifecycle metadata belongs to reusable state markers. Field values should carry UID, associated content UID, version, language, variant, field identifier, and field content. The
field_identifiermaps the stored value back to its schema field definition. - Decision recorded: Keep routing, workflow, and permission fields explicit on the content entity, and store flexible non-critical metadata in a JSON
metadatapayload. - Decision recorded: Treat
titleandsubtitleas reserved required schema fields in every variable fieldset, not as content metadata or dedicated content columns. - Implemented baseline: Added first-pass
ContentItemandContentFieldValueDoctrine entities plus the consolidated core baseline migration. Entities live underApp\Entityto keep Symfony's default Doctrine mapping conventions intact, while content-specific enums and routing guards live underApp\Content. - Implemented baseline: Added a public
PublishedContentResolverread layer that resolves published content by slug, custom URL, or hierarchy path, requires an active revision, applies view ACL rules, loads revision-scoped field values, exposes reservedtitleandsubtitlehelpers, falls back from missing languages and variants to defaults where available, emits warning messages for variant mismatches, and distinguishes missing/unpublished content from private/denied content. - Implemented baseline: Added schema
custom_twigrendering for the inner public content fieldset. The public content template owns page chrome and package injection slots, while the fieldset renderer uses custom schema Twig when available and falls back to the generic field renderer when custom Twig is empty or invalid. - Implemented baseline: Added low-priority Symfony routes for
/and catch-all content paths throughPublicContentController. The controller maps root URLs tocontent.home_path, validates non-root content paths against reserved prefixes, resolves published content, maps missing, unpublished, deleted, or unavailable content to404, maps ACL-denied content to401, maps private or reserved-prefix content to403, and renders a temporary neutral Twig response until the theme engine owns final public rendering. - Implemented baseline: Added unrestricted content path lookup with internal
/system/...support and a first redirect resolver that resolves internal route targets without changing the browser URL, supports externalhttp(s)targets through302, follows chains, detects loops, enforces a hop limit, and rejects unsupported URI schemes. - Decision recorded: Store variants alongside the canonical content entity. Language variants and generic variants remain separate concepts.
- Decision recorded: Slugs allow only lowercase ASCII letters, numbers, and
-as separator. Slugs must not start or end with-; special characters such as~remain available for routing features. - Decision recorded: Language routing uses configured leading locale prefixes such as
/de/slug. - Decision recorded: Localized public content routes are controlled by
localization.route_prefixes_enabled. When enabled, unprefixed content routes redirect with302to the best matching browser language where possible and otherwise to the configured default language prefix; available prefixes come from translation catalogues, and those prefixes are reserved against content-route conflicts. - Decision recorded: Generic variants use reserved marker path URLs such as
/slug/~compactfor public pretty URLs. Query-parameter variants such as/slug?variant=compactremain supported as fallback or preview/admin links. - Decision recorded: Missing generic variants render the default variant where possible and emit a warning message that later logging can collect.
- Decision recorded: Missing language variants render the default language when available, with fallback to another available language variant when the default language is not available.
- Decision recorded: Dynamic package view injections may attach physical Twig templates to public content render slots such as
before_contentandafter_content, or to missing variant route suffixes such as/slug/~comments, using declarative filters like real-content-only and schema identifiers. Real content variants keep priority over dynamic injected variant routes. - Decision recorded: Content rows should keep current searchable workflow state such as
status, while reusablestate_markerrows provide current/last lifecycle metadata such as created, modified, published, unpublished, archived, restored, deleted, locked, and unlocked. Reversals such as unpublishing should update current state (statusno longerpublished) and replace the relevant marker for fast UI lookup; complete transition history belongs in future audit logs. - Decision recorded: Custom Twig remains an intentional product capability for creative schema-driven fieldsets. Schema Twig may be edited only by users with explicit administrative/trusted permissions, must be validated before activation, and may later use sandbox or safety dictionaries. Ordinary content-body fields must not execute unsandboxed Twig.
- Decision recorded: Package Twig templates are trusted package code, but packages must not receive arbitrary access to internal users, ACL state, secrets, or protected configuration. Sensitive data access should go through core-provided providers that enforce permissions and redaction.
- Decision recorded:
ContentItemshould be split before Editor/API growth into smaller route/redirect, ACL, localization/variant, revision activation, and tree/sort boundaries while keeping a coherent content facade where useful. - Decision recorded: Content access control should keep Symfony roles as the primary authorization layer and layer optional ACL groups on top for entity, tree, schema, and package-domain permissions. Role and group logic stay separate and need explicit AND/OR semantics.
- Decision: provisional until API: API-facing content DTO/read-model shape will be finalized in the API feature branch after serializer, permission, fieldset, and public/private metadata decisions are concrete.