The Templates and Template Parts entities form the structural backbone of a site-editor experience: full-page templates (single, page, archive, etc.) and reusable parts (header, footer, sidebar, etc.) that templates compose.
Added in 2.0.0 (H1).
Templates
Storage
- Theme files:
themes/{active}/templates/{slug}.html - DB table:
templateswith columnsid,theme,slug,title,description,status,is_custom,block_content(JSON),author_id, timestamps. Unique constraint on(theme, slug).
REST endpoints
All endpoints live under /api/v1/ and require authentication (auth:sanctum).
| Method | Path | Purpose |
|---|---|---|
| GET | /templates |
List resolved templates (file + DB merged; DB wins per slug) |
| GET | /templates/{slug} |
Show single resolved template |
| POST | /templates |
Create DB-stored template (custom or override) |
| PUT | /templates/{slug} |
Upsert DB-stored template |
| DELETE | /templates/{slug} |
Revert (deletes DB row; theme file stays authoritative) |
Response shape
Mirrors WordPress's /wp/v2/templates:
{
"id": "{theme}//{slug}",
"slug": "page",
"theme": "digital-shopfront",
"type": "wp_template",
"source": "theme",
"origin": null,
"content": {
"raw": "<!-- wp:paragraph --><p>Hello</p><!-- /wp:paragraph -->",
"blocks": [
{ "name": "core/paragraph", "attributes": { "content": "Hello" }, "innerBlocks": [] }
],
"block_version": 1
},
"title": { "raw": "Page", "rendered": "Page" },
"description": "",
"status": "publish",
"wp_id": 0,
"has_theme_file": true,
"is_custom": false,
"author": 0,
"modified": null
}
idis always thetheme//slugform, matching WP exactly.wp_idcarries the DB row's integer ID separately (0 when only a theme file backs the slug).content.rawcarries the file contents for theme-file-sourced entities and is the empty string''for DB-stored entities.content.blockscarries the parsed block array for both sources. DB-stored entities return theirblock_contentcolumn verbatim; theme-file entities are parsed on resolve (since 2.7.2 — before that they returned[], so a block theme resolved to nothing anywhere in the stack).
cms-framework's HasBlockContent trait stores only the parsed block array — never a raw HTML mirror — so consumers requiring HTML render through the matching renderer package, and consumers needing the parsed tree read from content.blocks.
Block shape for theme-file entities
content.blocks is authoritative for both sources: a consumer reads it and never needs to fall back to content.raw. Which shape it carries for a theme file depends on whether visual-editor is installed:
| Install | Shape | Text recovery |
|---|---|---|
With artisanpack-ui/visual-editor ≥ 1.5.5 |
Editor shape ({name, attributes, innerBlocks}) — matches what DB rows store |
Yes — attributes.content and other source-declared attributes are recovered from the saved HTML |
| Standalone cms-framework | WP parse_blocks() shape ({blockName, attrs, innerBlocks, innerHTML, innerContent}) |
No — block text stays in innerHTML |
The split exists because Gutenberg persists most block text in the saved HTML rather than in the delimiter's JSON, so recovering it needs each block type's block.json source definitions — which live with the block partials in visual-editor, not here. ThemeFileBlockParser looks up visual-editor's BlockMarkupHydrator through the container and falls back to BlockMarkupParser's WP-shape output when it is absent.
Theme files are read and parsed on every resolve; there is no parse cache. all() therefore parses every template the active theme ships.
Conflict and validation behavior
POSTreturns 409 Conflict when a(theme, slug)row already exists, witherrors.slugset.PUTaccepts a body without a slug (route slug is canonical) and returns 422 when a body slug is present but does not match the URL slug.
Template parts
Storage
- Theme files:
themes/{active}/parts/{slug}.html - DB table:
template_parts— same shape astemplatesplus a requiredareacolumn.
Areas
Template parts are constrained to a closed list of area values: header, footer, sidebar, uncategorized, and navigation-overlay. These match WordPress's defaults (the legacy general area was renamed to uncategorized in 2.0.0 to align with WP core).
The closed list is enforced at the application layer:
- Form Request rejects payloads with any other value (HTTP 422).
- Theme-file parts whose slug starts with a known area prefix (
header-large,footer-mini) are auto-categorized into that area; everything else falls back touncategorized.
REST endpoints
Same shape as templates, but under /api/v1/template-parts. The response carries an additional area field and type is wp_template_part.
Resolver contract
Templates and template parts implement ArtisanPackUI\CMSFramework\Modules\SiteEditor\Resolution\EntityResolver:
interface EntityResolver
{
public function resolve(string $slug): ?ResolvedEntity;
public function all(): array;
public function revert(string $slug): bool;
}
ResolvedEntity is a value object carrying the merged source-of-truth: slug, theme, source ('db' or 'theme'), content (block string), title, description, status, hasThemeFile, isCustom, area (parts only), and the backing model (when source is 'db').

