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.
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.