Templates and patterns
The Block Template Hierarchy and How WordPress Resolves It
front-page, single, archive, 404 and the fallback to index.html, mapped onto the classic PHP hierarchy you already know from a classic theme.
You write templates/404.html, open a URL that does not exist, and the
blog index appears instead. Or you rename single.php to
single.html, activate the theme, open a post, and none of your markup is on
the page. Neither symptom is random. Both come from the same mechanism, and that
mechanism is the one you already learned for classic themes.
A block theme does not replace the template hierarchy. It keeps the hierarchy and changes what the winner looks like: a file of block markup instead of PHP. Everything you know about which template wins is still true, with one new layer stacked on top. Once you can see both layers, "which file renders this URL" stops being a guess.
WordPress still asks the classic questions, in the classic order
wp-includes/template-loader.php walks a fixed list of conditionals and
calls a getter for the first one that is true:
is_embed() -> get_embed_template()
is_404() -> get_404_template()
is_search() -> get_search_template()
is_front_page() -> get_front_page_template()
is_home() -> get_home_template()
is_privacy_policy() -> get_privacy_policy_template()
is_post_type_archive() -> get_post_type_archive_template()
is_tax() -> get_taxonomy_template()
is_attachment() -> get_attachment_template()
is_single() -> get_single_template()
is_page() -> get_page_template()
is_singular() -> get_singular_template()
is_category() -> get_category_template()
is_tag() -> get_tag_template()
is_author() -> get_author_template()
is_date() -> get_date_template()
is_archive() -> get_archive_template()
Two facts in that list explain most of the confusion people hit. The order matters more
than the names: a request where several conditionals are true is decided by whichever one
comes first. And is_singular() is a real stop on the list, after
is_single() and is_page(), not only a conditional you use in
if statements.
If nothing in the list returns a template, get_index_template() runs last.
That is the whole fallback story, and it is the same story classic themes tell.
Each getter builds a candidate list and stops at the first hit
get_query_template() takes the type and its candidates, applies the
{$type}_template_hierarchy filter, and hands the list to
locate_template(). locate_template() walks the list in order and
returns the first file that exists. The candidate builders are the interesting part,
because they are where specificity lives.
// wp-includes/template.php, WordPress 7.0.7
function get_front_page_template() {
$templates = array( 'front-page.php' );
return get_query_template( 'frontpage', $templates );
}
function get_home_template() {
$templates = array( 'home.php', 'index.php' );
return get_query_template( 'home', $templates );
}
function get_archive_template() {
$post_types = array_filter( (array) get_query_var( 'post_type' ) );
$templates = array();
if ( count( $post_types ) === 1 ) {
$post_type = reset( $post_types );
$templates[] = "archive-{$post_type}.php";
}
$templates[] = 'archive.php';
return get_query_template( 'archive', $templates );
}
Notice that get_front_page_template() has exactly one candidate, and
get_home_template() has two. That asymmetry is not a bug. It is why the front
page falls through to the blog index when front-page is absent, and why a
static front page falls through to the page hierarchy instead.
Table stakes: what each request tries, in order
The list below is the candidate array the getter passes to locate_template(),
with the leading theme directory stripped. In a block theme, the same list goes to
resolve_block_template(), which strips the .php or
.html suffix and looks for a matching template slug. The order is identical
in both cases.
| Request | Candidates, in the order tried | What the loader does when none match |
|---|---|---|
| A single post | a custom template applied to the post, single-post-{slug}, single-post, single |
Falls through to is_page(), then is_singular(), then index |
| A page | a custom template applied to the page, page-{slug}, page-{id}, page |
Falls through to is_singular(), then index |
| A category archive | category-{slug}, category-{id}, category |
Falls through to is_archive(), then index |
| A tag archive | tag-{slug}, tag-{id}, tag |
Same: archive, then index |
| An author archive | author-{nicename}, author-{id}, author |
Same: archive, then index |
| A date archive | date |
Same: archive, then index |
| A post type archive | archive-{post_type}, archive |
index |
| A search result page | search |
index |
| Anything with no route | 404 |
index |
| The front page | front-page |
Falls through to is_home(), then the page hierarchy, then index |
| The blog posts index | home, index |
Nothing left. index is the floor. |
Three rows are worth more than a glance.
Search does not fall back to archive. get_search_template()
returns a single candidate. If search.html is missing, the loader does not
try archive.html first; the is_search() getter returns nothing
and the loop lands on get_index_template(). A theme whose
index.html is a blog loop will happily show a list of posts for a query that
matched nothing.
The front page is not the blog index. is_front_page()
and is_home() are separate conditionals, and the block hierarchy in the
editor does not reflect the loader's fallback. get_template_hierarchy( 'front-page'
) returns front-page, home, index because
that is what the Site Editor offers you. The loader never tries home.html
for a static front page, and never tries it for the front page at all. It tries
front-page, gets nothing, and moves to the next conditional.
The Reading setting decides where the front page lands
With Reading set to latest posts, is_home() is true, so a front page
without front-page.html renders home.html. With Reading set
to a static page, is_home() is false, the loader skips to
is_page(), and the front page renders the page hierarchy. Same missing
file, two different outcomes.
Author and tag archives reach archive.html because the loader gets
a second turn. get_author_template() and
get_tag_template() do not include archive in their candidate
lists. They return nothing when their own files are absent, and the next matching
conditional in the loader list is is_archive(). The effect is the same, and
the reason is worth keeping, because a plugin that filters
author_template_hierarchy to add archive is solving a problem
that does not exist.
index.html is the only file that has to exist
A block theme is detected by the presence of templates/index.html on
disk; the older block-templates/ directory also counts.
current_theme_supports( 'block-templates' ) is what
locate_block_template() checks before doing anything at all, and that support
flag is set from the same detection. Delete index.html and three things
happen at once: the Site Editor will not open, locate_block_template()
returns the PHP template path untouched, and every other block template in the theme
becomes unreachable.
That is a detection rule, so it fails loudly. The other failure mode is quiet. A
missing single.html is not an error; it is a shorter candidate list. A
custom post type with no single-case-study.html renders
single.html, and a site whose index.html is a generic query loop
will serve a case study page through it without printing a single warning.
An empty template file is not a missing one
WordPress resolves an empty page.html, finds no content, and renders a
warning in its place for a logged-in user, offering to reset or edit it. Logged out, the
theme file's contents render instead. A zero-byte template therefore looks broken in the
editor and fine in a private window.
A database template always outranks a file of the same slug
Resolution reads two sources and prefers the first. User templates are posts of the
wp_template post type, tagged for the active theme with the
wp_theme taxonomy. Theme templates are files under templates/.
get_block_templates() queries the posts first, then adds theme files whose
slugs did not already appear, which is why the resolver reports one record per slug rather
than two.
<?php
$templates = get_block_templates( array( 'slug__in' => array( 'single', 'index' ) ) );
foreach ( $templates as $template ) {
printf(
"%s source=%s theme_file=%s\n",
$template->slug,
$template->source,
$template->has_theme_file ? 'yes' : 'no'
);
}
When a user edits a template in the Site Editor, or a plugin creates one through the
REST API at /wp/v2/templates, a wp_template post appears with
the same slug. The file becomes the fallback and the post becomes the source. The record
reports source: custom while still setting
has_theme_file: true, which is the API's way of saying "this overrides a file
that still exists". Delete the post and the file is in charge again, with
source: theme. Nothing was cached and nothing needed a rebuild, because the
file was never modified.
The reverse is the failure to watch for. If an override exists and the theme file carries the same slug, editing the file changes nothing the user sees. You have to clear the override first, which is the subject of the next lesson. The cause is here, in the resolution order.
get_template_hierarchy() is the editor's view, not the request path
Core has a function named get_template_hierarchy(), and the name suggests
it decides which file renders. It does not. It is called from the templates REST
controller, where it answers a different question: which templates exist around this one,
so the editor can offer the right reset and edit affordances.
Its output is still useful for prediction. On WordPress 7.0.7,
get_template_hierarchy( 'category' ) returns category,
archive, index, and get_template_hierarchy( 'single' )
returns single, singular, index. A custom template
passes true as the second argument and the chain becomes page,
singular, index, because a custom template has no place in the
automatic hierarchy and falls back like a page.
theme.json is how you declare a custom template, and the declaration is
what the site editor reads:
{
"version": 3,
"customTemplates": [
{
"name": "my-wide-template",
"title": "Wide, no sidebar",
"postTypes": [ "page", "post" ]
}
]
}
The file itself is just templates/my-wide-template.html. It is offered in
the page editor because postTypes says so, not because the filename follows a
rule. Give it a name that collides with a hierarchy slug such as page and you
have made a template that behaves in ways the table above will not explain.
Read what actually resolved instead of guessing
While a block template is rendering, locate_block_template() sets a global
named $_wp_current_template_id to the template's id: the theme stylesheet,
two slashes, then the slug. On the test theme used for this lesson that reads
wpsifero-base//front-page, and reading it back is the fastest way to confirm
a prediction.
<?php
add_action( 'wp_footer', function () {
if ( ! current_user_can( 'edit_theme_options' ) ) {
return;
}
global $_wp_current_template_id, $_wp_current_template_content;
printf(
'<!-- template: %s, %d characters of markup -->',
esc_html( (string) $_wp_current_template_id ),
strlen( (string) $_wp_current_template_content )
);
} );
Two caveats. The globals are set only while a block template is used, so on a request
that fell back to PHP they are empty, and empty is itself the answer. And
/wp/v2/templates lists every template the resolver can see along with a
source of theme or custom, which is the fastest way
to enumerate the hierarchy of a theme you did not write.
Take the block theme you built in module 1 and give it a full set of templates:
index.html, front-page.html, home.html,
single.html, singular.html, page.html,
archive.html, category.html, search.html and
404.html. Put one short paragraph in each naming the file it lives in,
so you can tell them apart on the front end.
Visit a post, a page, a category archive, a tag archive and a URL that does not exist. Write down the file you expect to render before you load the page.
Now remove files one at a time, reloading the same URLs after each removal, and
record what changes: single.html, then singular.html, then
search.html, then index.html.
Put everything back. Then create a wp_template post with the slug
single and different content, and answer two questions: which one
renders now, and what would you have to do to make the file win again?
Show the solution and the reasoning
The predictions follow the loader order, not the file names. A category archive
resolves category.html and never reaches archive.html, because
get_category_template() finds a hit before the loader reaches
is_archive(). A tag archive with no tag.html does reach it,
because get_tag_template() returns nothing and is_archive()
gets the next turn.
Removing single.html is the instructive one. The post does not disappear
and does not drop to index.html, because the loader keeps going and
is_singular() calls get_singular_template(), which resolves
singular.html. Remove that too and the post renders through
index.html.
Removing search.html is the one people get wrong. Nothing between search
and index matches, so get_index_template() is the last call in the loader
and index.html serves the search results. If your index.html
is a plain blog loop, a search for a term with no matches prints an empty list under a
heading that promises results.
Removing index.html is not a hierarchy event at all. The theme stops
being a block theme, current_theme_supports( 'block-templates' ) goes
false, locate_block_template() returns the PHP path untouched, and the front
end falls back to the classic template path. If the theme has no
index.php, requests resolve through whatever WordPress does with a theme
that provides nothing.
The last step is the one that explains most support tickets. The
wp_template post wins, because user templates are queried before theme
files and a match at the same slug short-circuits the file. Editing
single.html changes nothing while that post exists, and neither does
reinstalling the theme. You make the file win by removing the override, either by
deleting the post or by clearing the customization from the Site Editor.
To see the layered state rather than the winner, query the resolver:
<?php
$templates = get_block_templates( array( 'slug__in' => array( 'single', 'index' ) ) );
foreach ( $templates as $template ) {
printf(
"%s source=%s theme_file=%s\n",
$template->slug,
$template->source,
$template->has_theme_file ? 'yes' : 'no'
);
}
On the test site that prints single source=custom theme_file=yes while an
override exists, and single source=theme theme_file=yes once it is cleared.
The slug appears once rather than twice, because the resolver collapses the layers for
you. When a theme update "does not apply", this is the query that shows why, and it is
faster than clearing caches in the hope that the file wins.
Progress
Mark this lesson done and it stays marked in this browser.