Skip to content
System nominalUTC --:--:--Articles 041
MODULE 02 Lesson 04 / 06 30 MIN

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.

RequestCandidates, in the order triedWhat 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.

Lab 35 minutes

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.