WordPress 5.9 arrived on the twenty-fifth of January with full site editing, which is a phrase that undersells what changed. A theme is no longer a set of PHP files that decide what to render — it is a directory of HTML files containing block markup, and the site editor can rewrite any of them into the database.
The symptom
$ ls *.php | wc -l
41
$ ls
index.php single.php archive.php page.php header.php
footer.php functions.php sidebar.php searchform.php
...
# and in the editor, on 5.9:
# Appearance → Editor is absent, because this is a
# classic theme, and every block the client adds is
# styled by a stylesheet they cannot see.The theme worked and continued to work — classic themes are not deprecated and 5.9 does not break them. The problem was that every layout change was a developer task, and the editor screen that would have made it a client task was not available.
Why it happens
Two rendering paths grew independently: the PHP template hierarchy, which has decided what to render since 2003, and the block editor, which decides what content looks like inside a template. Nothing connected them, so a theme could style the front end and the editor separately and they could disagree.
A block template is the connection. Both the editor and the front end render the same block markup through the same code, which means what an editor sees is what a visitor gets — and it means the template itself has to be expressible as blocks.
The fix
What the directory actually is
theme/
style.css still required, still the theme header
theme.json settings and styles, version 2
templates/
index.html the ONLY required template
single.html
archive.html
404.html
page-contact.html
parts/
header.html
footer.html
patterns/
cta.php
no index.php. functions.php is optional.The naming follows the template hierarchy exactly — single-product.html beats single.html beats index.html — so the resolution rules are the ones everybody already knows with a different extension. What is gone is the ability to put logic in a template.
<!-- templates/single.html -->
<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-featured-image {"aspectRatio":"16/9"} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
<!-- wp:post-terms {"term":"post_tag"} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->
A self-closing block comment is a dynamic block with no saved markup, which is why post-title has no HTML around it — it renders at request time from the current query. The template is a composition of those, and that is the whole file.
theme.json v2, and the migration from v1
{
"version": 2,
"settings": {
"appearanceTools": true,
"layout": { "contentSize": "48rem", "wideSize": "72rem" },
"color": {
"custom": false,
"palette": [
{ "slug": "brand", "color": "#1f6feb", "name": "Brand" },
{ "slug": "ink", "color": "#101418", "name": "Ink" }
]
},
"spacing": { "blockGap": true, "units": ["px", "rem"] }
},
"styles": {
"spacing": { "blockGap": "1.5rem" },
"elements": {
"link": { "color": { "text": "var(--wp--preset--color--brand)" } }
}
},
"templateParts": [
{ "name": "header", "title": "Header", "area": "header" },
{ "name": "footer", "title": "Footer", "area": "footer" }
]
}
v1 → v2, the renames that matter:
settings.spacing.customPadding → settings.spacing.padding
settings.border.customRadius → settings.border.radius
settings.typography.customLineHeight → .lineHeight
settings.color.customGradient → .customGradient (kept)
and the two sections that are NEW:
templateParts — declares the areas
customTemplates — the ones an author can select
layout.contentSize replaces the theme support call, and
controls the width of every constrained group on the site.The layout change has the widest blast radius: a value that used to affect only the editor now controls the rendered width of every group with a constrained layout, which on a site converted from a classic theme is every group. Getting it wrong produces a site that is subtly the wrong width everywhere and nothing points at the file.
Template parts, and the header that became content
<!-- parts/header.html -->
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:group {"layout":{"type":"flex","justifyContent":"space-between"}} -->
<div class="wp-block-group">
<!-- wp:site-logo {"width":48} /-->
<!-- wp:navigation {"ref":41} /-->
</div>
<!-- /wp:group -->
</div>
<!-- /wp:group -->
The navigation block referencing a post id is the piece that surprises people: a menu is now a wp_navigation post rather than a menu in the Appearance screen, and the reference in the file points at an id that does not exist on another installation. A part without the ref falls back to creating one from the existing menu, which is the migration path and only runs once.
That makes the theme not quite portable — deploying this file to a fresh site produces a navigation block pointing at nothing. Omitting the ref and letting it resolve is the version that works across environments, at the cost of the two sites diverging afterwards.
What has no equivalent
things a PHP template did that a block template cannot:
a conditional if ( is_user_logged_in() )
a query with a a WP_Query with a meta_query, a custom
custom argument orderby, or a filter applied
a computed value a total, a count, a formatted date in
a format the block does not offer
a third-party a shortcode is fine; a function call
integration is not
the answers, in order of preference:
1 a block that already does it
2 a dynamic block with a render callback
3 a shortcode inside a shortcode block
4 render_block filter — powerful, and unreadableMost of the forty-one templates needed nothing beyond core blocks, which was the surprise — the logic that felt essential was mostly conditional wrappers that a group block with a class replaced. Four needed a custom dynamic block, and two of those were the same block used twice.
// the one that needed a render callback: a price with a
// currency the visitor chose
register_block_type( __DIR__ . '/build/price', array(
'render_callback' => function ( array $attributes, string $content, WP_Block $block ): string {
$post_id = $block->context['postId'] ?? get_the_ID();
return sprintf(
'<span class="%s">%s</span>',
esc_attr( get_block_wrapper_attributes()['class'] ?? '' ),
esc_html( turkerdev_format_price( $post_id ) )
);
},
) );
The block context is what makes a dynamic block work inside a query loop — postId is provided by the parent block rather than by the global query, and reading the global instead produces a block that shows the same post in every row. That is the single most common bug when converting a template part into a block.
The hybrid theme, which is the answer for an existing site
a classic theme can adopt theme.json without becoming a
block theme:
theme.json alone → editor settings, global styles,
generated custom properties
+ block_template_part()→ template parts in PHP templates
+ templates/index.html→ NOW it is a block theme, and the
PHP hierarchy stops being used
the flip is abrupt. there is no partial state.// header.php, in a classic theme, rendering a block part
<?php
if ( function_exists( 'block_template_part' ) ) {
block_template_part( 'header' );
} else {
get_template_part( 'template-parts/header' );
}
Rendering block parts from PHP templates is what makes a genuine gradual migration possible: the header and footer become editable in the site editor while every other template stays PHP. It is not a documented migration path so much as a consequence of the functions being public, and it worked well enough to move eleven templates over three months.
The database copy, which is the operational difference
$ wp post list --post_type=wp_template
--fields=post_name,post_modified --format=table
+-----------+---------------------+
| single | 2022-03-14 09:41:02 | ← customised
| header | 2022-02-01 11:20:44 |
# from this moment, templates/single.html is ignored for
# this site. a theme deploy changing it does nothing.
# the adoption workflow:
$ wp post get "$id" --field=content > templates/single.html
$ git add templates/single.html && git commit
$ wp post delete "$id" --force
This is the largest practical difference between a block theme and a classic one, and it is not mentioned in any of the announcement material. A template edited in the site editor becomes a wp_template post that permanently overrides the file, with no merge, no notification and no indication anywhere a developer looks.
A deploy check that fails when any template is customised is the mechanism that keeps the repository meaningful. Without it, six months later the theme files describe a site that has not existed since March.
Verifying it worked
$ ls *.php | wc -l
1 # functions.php, for the four dynamic blocks
$ npx ajv-cli validate
-s https://schemas.wp.org/wp/5.9/theme.json
-d theme.json --strict=false
theme.json valid
$ wp post list --post_type=wp_template --format=count
0 # nothing customised; the files are the truth
$ npx backstop test
Passed: 38 Failed: 3
# all three: 2px of blockGap. accepted and rebaselined.The visual regression run is what makes this conversion reviewable at all, because the markup changes completely and the rendered output should not. Three differences out of forty-one, all spacing, all traceable to blockGap replacing a hand-written margin, is a good outcome and each one needed a person to look at it.
The schema validation catches the class of error a visual check cannot: a misspelled key in theme.json is silently ignored, so a setting that does nothing looks exactly like a setting that works.
What this costs
A design system that is now partly in the database. Global styles, navigation menus and any customised template live in wp_posts rather than in the repository, which means a staging environment does not match production unless the content is copied and a deploy does not deliver a design change unless nobody has edited that template. Both are manageable and neither is how a theme worked before.
The other cost is that the escape hatches are worse. A PHP template could do anything; a block template can do what blocks do, and the fourth option — filtering render_block — is powerful, unreadable and invisible to anybody reading the template. Four dynamic blocks was an acceptable amount of custom code and the temptation to write a fifth for something a group block nearly does is constant.
It is also worth being clear that this is version one of a large change. theme.json moved from v1 to v2 in six months, the webfonts API shipped and changed within a release, and block locking arrived in 6.0 because 5.9’s templateLock was too coarse. Adopting this in January 2022 means revisiting it, and the alternative is a classic theme that works and that clients cannot edit.