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

Templates and patterns

Patterns and Synced Patterns in Block Themes

Registering patterns in PHP and in the patterns directory, scoping them by block and post type, and why a synced pattern behaves nothing like a copy.

A client asks for a call-to-action block they can drop into pages, and you register a pattern. It works. A week later they say the pattern does not appear in the inserter on their case studies, and the same day you notice the heading in it is wrong on three pages, which you cannot fix in one place because it is not one place any more.

Both complaints come from the same two questions. Where is this pattern allowed to appear, and is what the user inserted a pointer to the pattern or a copy of it? Answer those before you register anything, and the surprises stop.

register_block_pattern() takes a name, and the name needs a namespace

Pattern registration is a PHP call on init. The function is register_block_pattern(), and the first argument is the pattern name including a namespace:

<?php
add_action( 'init', function () {
    register_block_pattern(
        'wpsifero-base/hero',
        array(
            'title'       => __( 'Hero with two calls to action', 'wpsifero-base' ),
            'description' => __( 'A heading, a short paragraph and two buttons.', 'wpsifero-base' ),
            'categories'  => array( 'wpsifero-sections' ),
            'content'     => '<!-- wp:heading --><h2 class="wp-block-heading">Ship it</h2><!-- /wp:heading -->',
        )
    );
} );

That call returns true. Call it without a title and it returns false instead, printing a _doing_it_wrong notice that most production sites never show. Call it without content and it also returns false, unless you passed filePath instead, in which case WordPress reads the markup from the file. Title is the one required property; content or filePath is the other half.

The pattern name is what everything else refers to. It appears in the REST response from /wp/v2/block-patterns/patterns as name, under the same wpsifero-base/ namespace you gave it, and it is the key you would pass to unregister_block_pattern(). Two characters are worth avoiding in it: the name is matched against ^[A-z0-9\/_-]+$ in the file-based loader, so spaces and dots are out.

A category has to exist before a pattern can use it

categories in the pattern properties is a list of names, and each name refers to a category registered separately. Registering the pattern first does not create the category, and the pattern still registers; it just renders without a group in the inserter.

<?php
add_action( 'init', function () {
    register_block_pattern_category(
        'wpsifero-sections',
        array( 'label' => __( 'WPSifero sections', 'wpsifero-base' ) )
    );
} );

Core ships a set of its own categories, and the REST response for /wp/v2/block-patterns/categories on a stock WordPress 7.0.7 install includes featured, call-to-action, team, testimonials, services, contact, about, portfolio, gallery, media, videos, audio, posts, footer, header, navigation and more. Reusing one of those is legal and often correct; there is no rule that a theme must namespace its categories, only that the name it uses must exist.

What a category does not do is restrict where a pattern appears. It is a label in the inserter. Every pattern in every category is offered everywhere the other two headers allow it, so a pattern filed under "Footers" still shows up in the general inserter.

Block Types and Post Types are the two scope levers, and they scope differently

Both properties are arrays of names, and they behave nothing alike.

postTypes is a restriction. The pattern is available while editing one of the listed post types and is not available at all for any other post type. Omit the property and the pattern is available everywhere.

blockTypes is a suggestion with a specific meaning. It names the blocks that could use this pattern in a contextual setting, which is how a pattern appears inside a block's own placeholder or as a block transform. Core's own documentation on WP_Block_Patterns_Registry::register() is explicit that the pattern remains available in the general inserter regardless of this list. Block types can carry further specificity: core/template-part/header targets the header area of the template part block rather than the block as a whole.

<?php
add_action( 'init', function () {
    register_block_pattern(
        'wpsifero-base/section-header',
        array(
            'title'      => __( 'Section header for parts', 'wpsifero-base' ),
            'blockTypes' => array( 'core/template-part/header' ),
            'postTypes'  => array( 'page', 'post' ),
            'content'    => '<!-- wp:heading {"level":1} /-->',
        )
    );
} );

Read that pair carefully, because it is the combination people get wrong. This pattern is offered for a template part header area and it is unavailable when editing anything that is not a page or a post. The blockTypes entry does not hide it from the inserter, and the postTypes entry does not make it appear in header areas on a custom post type.

A file in patterns/ is the same pattern with headers instead of arguments

Theme patterns live in patterns/ as PHP files, and the metadata goes in a file header comment. This is the form you commit, because it travels with the theme directory instead of living in a function.

<?php
/**
 * Title: Hero with two calls to action
 * Slug: wpsifero-base/hero
 * Categories: wpsifero-sections, featured
 * Block Types: core/cover
 * Post Types: page
 * Viewport Width: 1280
 * Description: A heading, a short paragraph and two buttons.
 */
?>
<!-- wp:heading --><h2 class="wp-block-heading">Ship it</h2><!-- /wp:heading -->

Title and Slug are required, and the slug must survive the same character rule as the PHP name. The list of headers core reads is Title, Slug, Description, Viewport Width, Inserter, Categories, Keywords, Block Types, Post Types and Template Types. Everything after Title and Slug is optional, and the comma-separated ones are parsed into arrays for you, so Categories: wpsifero-sections, featured produces the same two-element array the PHP form produces.

Two behaviours of the file loader matter in practice. Its results are cached in a site transient keyed by theme version, so changing a pattern file without bumping the theme version can leave the old copy registered until the cache expires; setting WP_DEBUG or editing the theme's version string clears the way. And the loader skips a slug that is already registered, which is the seed of the trap in the last section.

A synced pattern is a pointer, and a copy is a copy

When a user inserts a normal pattern, the markup lands in the post. Nothing else exists. Edit that post later and the pattern is unaffected; edit the pattern and the post is unaffected. There is no database row and nothing to look up.

When a user creates a synced pattern, WordPress creates a wp_block post holding the markup and inserts a single block that points at it:

<!-- wp:block {"ref":42} /-->

core/block is the block name, ref is the post id, and the render callback loads the post named by ref and renders its content at that point. Change the wp_block post and every page holding a reference changes on the next load. That is the entire sync mechanism, and it is worth reading the failure modes out of it.

Published references render. A reference to a post that does not exist, or one that is not of type wp_block, returns an empty string rather than an error. A reference to a trashed pattern returns an empty string too, which is why trashing a synced pattern silently removes every instance of it from the site.

Not every wp_block row is synced

An unsynced pattern, which the editor labels as a copy, is also a wp_block post. The difference is the wp_pattern_sync_status post meta value of unsynced. An unsynced pattern's markup is inlined where it is used rather than referenced, so editing it later updates the library entry but not the copies already placed. The row exists so the pattern is editable and listable in the Patterns screen, not so it can be synced.

For a theme author the consequence is narrow and firm. A pattern you register in PHP or in patterns/ is read-only on the user's site. There is no interface for editing it in place, because there is no row to edit. If a user wants their own version, they copy it into a wp_block post, at which point it belongs to them and your future edits to the file cannot reach it. That is the correct behaviour and it is also why "fixed the pattern, still wrong on their site" is a recurring support thread.

The trap: a database pattern and a PHP file can share a slug without merging

Patterns come from two sources and the registry has one slot per name. Theme patterns are registered by the file loader, and user patterns are entities of the wp_block post type, reached through the REST API at /wp/v2/blocks. Both surface in the same inserter.

The collision case is a theme pattern whose slug matches something already registered. The file loader checks is_registered() before registering and skips the file when the name is taken. PHP registrations through register_block_pattern() overwrite the existing entry, which is the effect you want from a child theme and the effect you do not want from a plugin that grabbed the same namespace.

<?php
// Diagnostic: which source won, and what the registry thinks it has.
$registry = WP_Block_Patterns_Registry::get_instance();
$pattern  = $registry->get_registered( 'wpsifero-base/hero' );

if ( $pattern ) {
    printf(
        "title=%s registered_from=%s\n",
        $pattern['title'],
        $pattern['filePath'] ?? 'PHP, or inline content'
    );
} else {
    echo "not registered\n";
}

Run that on a site where the pattern renders old markup and the answer is usually that the registered copy carries no filePath, which means it did not come from the theme directory at all. Something registered the same name earlier, and the file is invisible for as long as that holds.

The fix follows from the diagnosis. If a plugin or a child theme owns the name, rename one of them; do not try to force the file to win by editing it. If the stale copy is a cache, bump the theme version. And if the pattern that "will not update" is actually a wp_block post the user copied, no amount of theme deployment will ever change it, because it stopped being yours the moment it was copied.

Lab 35 minutes

Register a pattern category called wpsifero-sections with a label, then register two patterns in PHP against it. The first is a call to action that should only be offered on pages. The second is a header layout that should be offered for the header area of a template part.

Load the editor on a page, a post and a case study custom post type, and write down in which of the three each pattern appears. Then reproduce the same two patterns as files in patterns/ with equivalent headers, and confirm they register with the same names and the same scoping.

Now create a synced pattern in the editor, place it in two posts, and edit the pattern's paragraph. Reload both posts.

Finally, trash the synced pattern and reload again. Then answer: what is stored in each of the two posts while the pattern is published, and what is left of it after the pattern is trashed?

Show the solution and the reasoning

With postTypes set to array( 'page' ), the call to action appears on pages and nowhere else. With blockTypes set to array( 'core/template-part/header' ) and no postTypes, the header layout appears in the general inserter for all three post types and additionally in the header area placeholder. Those two outcomes are the whole difference between the levers, and the lab is designed so you see the second pattern fail to restrict anything.

<?php
add_action( 'init', function () {
    register_block_pattern_category(
        'wpsifero-sections',
        array( 'label' => __( 'WPSifero sections', 'wpsifero-base' ) )
    );

    register_block_pattern(
        'wpsifero-base/cta-pages-only',
        array(
            'title'      => __( 'Call to action, pages only', 'wpsifero-base' ),
            'categories' => array( 'wpsifero-sections' ),
            'postTypes'  => array( 'page' ),
            'content'    => '<!-- wp:buttons /-->',
        )
    );

    register_block_pattern(
        'wpsifero-base/part-header',
        array(
            'title'      => __( 'Header layout for parts', 'wpsifero-base' ),
            'categories' => array( 'wpsifero-sections' ),
            'blockTypes' => array( 'core/template-part/header' ),
            'content'    => '<!-- wp:site-title /-->',
        )
    );
} );

The file versions are the same patterns with the arguments moved into a header comment. Registering both forms under the same slug is a mistake worth making once, because it shows which one wins: the PHP call overwrites the file registration, since the file loader checks is_registered() and the PHP call does not. In a real theme, ship one form or the other.

<?php
/**
 * Title: Call to action, pages only
 * Slug: wpsifero-base/cta-pages-only
 * Categories: wpsifero-sections
 * Post Types: page
 */
?>
<!-- wp:buttons /-->

While the synced pattern is published, each of the two posts stores one line and no content of its own:

<!-- wp:block {"ref":42} /-->

That is why editing the pattern changed both posts at once. There is one copy of the markup in the site and three places that ask for it, and the reference is resolved when the page renders rather than when it is saved.

After trashing the pattern, both posts still contain the same single line and both render nothing. The reference is intact and the target no longer qualifies, and render_block_core_block() returns an empty string rather than an error. If you had deleted the pattern permanently instead, the outcome would be identical from the reader's side. This is the sharpest argument for the rule that a synced pattern is a dependency: it is one row, it can be trashed from an admin list by someone who does not know what depends on it, and there is no warning before every instance disappears.

A copied pattern would have behaved the opposite way in both tests. It would have stored the full markup in each post, editing the original would have changed nothing, and trashing the original would have left both posts rendering exactly as before. Neither behaviour is better; they answer different questions, and the mistake is choosing one while expecting the other.

Progress

Mark this lesson done and it stays marked in this browser.

Course complete

That is the last lesson of WordPress Block Themes and Full Site Editing. If the material was useful, the practice is what makes it stick: rebuild the final project from an empty directory without looking back at the lesson.