Page templates
A Wonderpress page template is a PHP file plus a manifest. The PHP renders the page; the manifest at .wonderpress/manifest/page-templates/template-<name>.json in the theme declares the composition and the editor contract for pages saved to that template.
Creating a template
wonderpress template create --name Landing --lock all \ --section hero-main:landing-hero --section quotes:testimonials
That writes template-landing.php in the theme, the manifest (schemaVersion 1), and per-page Static Kit assets (a landing.scss and landing.js entry, by delegation). Each --section id:partial row becomes a real render() call in the scaffolded PHP:
( new \Wonderpress\Partials\Landing_Hero( wonder_partial_props( 'landing-hero', 'hero-main' ) ) )->render();After create, keep working in the PHP file and the manifest together. wonderpress template list shows what exists; wonderpress template remove deletes the PHP, the manifest, and the delegated Static Kit assets (--no-static keeps the static files).
The manifest
If partial manifests are the component dictionary, page-template manifests are the sentence: an ordered declaration of what a page is made of and what an editor may do to it. (Background on the manifest system as a whole, including syncing and drift, is on the Manifests page.) Everything that normally lives as scattered editor configuration (which blocks are locked, which ACF groups appear, which native panels show) is stated in one reviewable file per template. That makes the editor contract diffable in a pull request, reproducible across environments, and legible to agents, and it is what lets wonderpress-core register one coherent ACF group per template instead of per-field-group guesswork.
{ "$schema": "../schema/page-template.schema.json", "schemaVersion": 1, "template": "template-landing.php", "editor": { "lock": "all", "acf": { "tabPlacement": "left" }, "native": { "featuredImage": false } }, "composition": [ { "id": "hero-main", "partial": "landing-hero" }, { "id": "seo", "label": "SEO", "properties": [ { "name": "meta_description", "type": "string" } ] } ]}
Manifests must be strict JSON: no comments, no trailing commas. A parse error skips the whole file, and located partials fall back to per-slug ACF groups, which can look like fields “went global.” The $schema pointer gives your editor completion for keys, property types, lock values, and tab placement while you type, so inline errors match what the commands accept.
Top-level keys
Required keys: schemaVersion, template. No other keys are allowed at the top level.
| Key | Type | Description |
|---|---|---|
$schema | string | Points the editor at ../schema/page-template.schema.json. Leave as-is; the CLI stamps it. |
schemaVersion | integer | Always 1. Required. |
template | string | WordPress page template filename, such as template-landing.php. Required. Rules apply to pages whose saved _wp_page_template matches this. |
editor | object | How the editor behaves on pages using this template: lock, native, acf. |
composition | array | Ordered rows. A row is a partial, a fields group, or a tab, and has exactly one of partial, properties, or items. |
editor.lock
Same values as the wonderpress_template_locks filter: "all" (nothing moves), "insert" (reorder only), false (open composition). The manifest sets the default; a PHP filter on the same template key still wins. See Curating and locking the editor.
editor.native
Which native editor panels appear, as booleans: title, excerpt, featuredImage, discussion, and blockEditor. blockEditor: false switches the template to the classic screen; featuredImage: false removes featured-image support on that template. After changing Page → Template on a page, click Update and reload the edit screen so PHP can apply the manifest.
editor.acf
ACF field-group options for the template. tabPlacement forces left (sidebar tabs) or top (horizontal tabs) for composition tab rows; omit it to auto-pick (left for one tab row, top for two or more).
composition
An ordered list of rows, three kinds:
- partial rows,
{ "id", "partial" }: a reusable slice. Maps to an ACF group built from the partial’s manifest properties, and to arender()call in the PHP. - fields rows,
{ "id", "label"?, "properties": [ … ] }: an editor-only ACF group using the same property types as partial manifests. You write the HTML in the page PHP and read values withwonder_template_composition_field( 'your-id' )(orget_field( 'your-id' )). - tab rows,
{ "id", "label", "items": [ … ] }: group partial and fields rows as ACF tabs. Tabs do not nest, and a tab has no PHP of its own.
Ids are lowercase letters, numbers, and hyphens, and must be unique across the whole tree, including rows inside tabs. label is optional on every row and defaults to a title derived from the id. Fields-row properties use the same property objects as partial manifests, documented in the Partial manifest reference; a partial value is the slug, meaning the filename under .wonderpress/manifest/partials/ without .json. When composition lists ACF-compatible partials, wonderpress-core registers one field group on the template, with each instance id as a group field name. Hydrate in the PHP with wonder_partial_props( '<partial-slug>', '<instance-id>' ).
wonder_render_template_sections() still exists for a page that is nothing except named partials in order. Do not mix it with handwritten renders; pick one style per template.
Validation
wonderpress template validate # every templatewonderpress template validate Landing # one
Reports invalid composition (unknown partials, duplicate ids, bad property types) and rows the template PHP does not render. A partial row needs its wonder_partial_props( 'slug', 'id' ) call; a fields row needs wonder_template_composition_field( 'id' ). Commented-out examples from the scaffold are ignored. wonderpress lint runs the same check, so CI catches a manifest and PHP that disagree.
The JSON Schema cannot know whether a partial slug exists in your theme or whether two rows share an id; template validate is what checks those.
Assigning pages
Manifest rules apply only to pages whose saved _wp_page_template matches the manifest’s template value. Assign in the editor: Page → Template → your template, then Update. Pages on the default template keep the normal block editor.
A bespoke, code-rendered landing page typically hydrates from ACF and locks "all". On a blog post you insert the block version of a component instead, and the ACF group stays off that screen. The placement rules are covered in Working with ACF.