Block Hooks
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
Block Hooks let a block insert itself automatically before, after, or inside every instance of another block type,
without anyone editing a template: the block-theme counterpart of the action hooks and the_content filters classic
themes use to place plugin output. The hooked block is a real block, so the site owner can move, restyle or remove it
in the Site Editor, and that decision is kept. For plugin and block developers, this page builds lumen/local-bookshop
("Buy at your local bookshop") in the lumen-catalog plugin, inserts it after the Post Content block of the lumen
theme’s single book template, restricts and configures it with filters, and follows what happens when an
administrator edits that template.
This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the
version to run in production), PHP 7.4 minimum and 8.3+ recommended, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and
checked on 2026-10-28; WordPress 7.2, in beta and due in December
2026, changes nothing here so far. The HTML, REST responses and stored templates shown below come from a fresh 7.1.3
site running the lumen theme, and the block was built with a real wp-scripts build. Block registration itself is
covered in block.json and Registration, and render.php in
Static and Dynamic Blocks.
What Block Hooks do
A hooked block names an anchor block type and a relative position:
block.json value |
PHP value (filters) | Where the hooked block goes |
|---|---|---|
|
|
Sibling immediately before each anchor block. |
|
|
Sibling immediately after each anchor block. |
|
|
First inner block of each anchor block (the anchor must accept inner blocks). |
|
|
Last inner block of each anchor block. |
block.json uses camelCase; WordPress maps it to the snake_case values that get_hooked_blocks() returns and that
every filter receives. A misspelled position is skipped silently, and a block that names itself as anchor is skipped
with a _doing_it_wrong() notice ("Cannot hook block to itself").
Insertion happens on the server, while WordPress loads block markup, never in the stored content of the theme files:
| Content | Since |
|---|---|
Templates and template parts from theme files, and patterns registered by themes, plugins or the Pattern Directory |
6.4 |
Templates and template parts the user edited in the Site Editor (stored as |
6.5 |
|
6.5 |
|
6.7 |
Synced patterns ( |
6.8 |
Post content of any post type, as |
6.8 |
The same markup reaches the editor: the REST API returns templates, template parts, navigation menus and (for the post
types listed in the rest_block_hooks_post_types filter, 7.0) posts with the hooked blocks already inserted, so the
Site Editor shows them as ordinary blocks. On the anchor block, the Settings sidebar shows a Plugins panel ("Manage
the inclusion of blocks added automatically by plugins.") with one toggle per hooked block, grouped by namespace; the
toggle removes the hooked block or puts it back.
In 6.4 hooked blocks had to be dynamic, always had default attributes and skipped user-modified templates; 6.5 lifted the last two limits (filters and metadata below). Dynamic blocks remain the safe choice: their self-closing markup cannot fail validation.
The lumen/local-bookshop block
Every book page should end with a link that looks the book up, by ISBN, at an independent bookshop. The block is
dynamic, reads lp_isbn from the current post through block context, and hooks itself after core/post-content:
{
"$schema": "https://schemas.wp.org/wp/7.1/block.json",
"apiVersion": 3,
"name": "lumen/local-bookshop",
"version": "0.1.0",
"title": "Buy at your local bookshop",
"category": "lumen-catalog",
"icon": "store",
"description": "A link that finds the current book at an independent bookshop near the reader.",
"keywords": [ "bookshop", "buy", "isbn" ],
"textdomain": "lumen-catalog",
"attributes": {
"label": {
"type": "string",
"default": "Buy at your local bookshop"
},
"campaign": {
"type": "string",
"default": ""
}
},
"usesContext": [ "postId", "postType" ],
"supports": {
"html": false,
"multiple": false,
"align": [ "wide" ],
"color": {
"background": true,
"text": true
},
"spacing": {
"margin": true,
"padding": true
}
},
"blockHooks": {
"core/post-content": "after"
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"render": "file:./render.php"
}
blockHooks is an object, so a block can name several anchors, but only one position per anchor. "multiple": false
matters here: WordPress does not insert a hooked block whose type disallows multiple instances when the content
already contains one, so a site owner who moves the link elsewhere in the template does not get a second copy. The
side effect is covered in Keeping user edits: ignoredHookedBlocks.
The template renders nothing for posts that are not books or have no ISBN, so a hook that fires in an unexpected template produces no output:
<?php
/**
* Renders lumen/local-bookshop. Receives $attributes and $block (with postId and postType context).
*
* @package Lumen\Catalog
*/
defined( 'ABSPATH' ) || exit;
$lumen_post_id = $block->context['postId'] ?? 0;
if ( 'lp_book' !== ( $block->context['postType'] ?? '' ) || ! $lumen_post_id ) {
return;
}
$lumen_isbn = get_post_meta( $lumen_post_id, 'lp_isbn', true );
if ( '' === $lumen_isbn ) {
return;
}
$lumen_url = add_query_arg(
array_filter(
array(
'isbn' => rawurlencode( $lumen_isbn ),
'utm_campaign' => rawurlencode( $attributes['campaign'] ),
)
),
'https://shops.lumenpress.example/find'
);
?>
<p <?php echo get_block_wrapper_attributes(); ?>>
<a href="<?php echo esc_url( $lumen_url ); ?>" rel="noopener">
<?php echo esc_html( $attributes['label'] ); ?>
</a>
</p>
The editor script only needs an edit function, because a dynamic block saves nothing:
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import metadata from './block.json';
import './style.scss';
function Edit( { attributes } ) {
return <p { ...useBlockProps() }>{ attributes.label }</p>;
}
registerBlockType( metadata.name, { edit: Edit } );
The block lives in lumen-catalog/src/blocks/local-bookshop/ next to the other Lumen blocks. The plugin’s existing
build (wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest) compiles it, writes
index.asset.php with the dependencies react-jsx-runtime, wp-block-editor and wp-blocks, and adds the block to
build/blocks/blocks-manifest.php, which wp_register_block_types_from_metadata_collection() registers on init.
Once the plugin is active, get_hooked_blocks() (6.4) lists the hook, grouped by anchor and position:
wp eval 'echo wp_json_encode( get_hooked_blocks() );'
{"core/post-content":{"after":["lumen/local-bookshop"]}}
The anchor in the lumen theme
The lumen theme’s templates/single-lp_book.html contains one Post Content block, the anchor:
<!-- wp:template-part {"slug":"header","area":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","area":"footer"} /-->
The theme file is unchanged, yet https://lumenpress.test/books/the-lighthouse-ledger/ shows the block after the content:
<main id="wp--skip-link--target" class="wp-block-group is-layout-constrained wp-block-group-is-layout-constrained">
<h1 class="wp-block-post-title">The Lighthouse Ledger</h1>
<div class="entry-content wp-block-post-content is-layout-constrained wp-block-post-content-is-layout-constrained"><p class="wp-block-paragraph">A keeper keeps accounts of every ship.</p></div><p class="alignwide wp-block-lumen-local-bookshop">
<a href="https://shops.lumenpress.example/find?isbn=978-1-4028-9462-6&utm_campaign=single-book" rel="noopener">
Buy at your local bookshop </a>
</p>
</main>
alignwide and utm_campaign come from the filter in the next section; block.json alone inserts default attributes.
Filtering where and how the block is inserted
blockHooks is unconditional: every core/post-content in every template, template part and pattern is an anchor,
including single.html, page.html and patterns that have nothing to do with books. Two filters, both in
lumen-catalog/src/BlockHooks.php, narrow and configure the insertion:
<?php
/**
* Narrows and configures the hooked lumen/local-bookshop block.
*
* @package Lumen\Catalog
*/
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
final class BlockHooks {
const BLOCK = 'lumen/local-bookshop';
/**
* Registers the filters. Called from Plugin::boot().
*/
public static function boot(): void {
add_filter( 'hooked_block_types', array( self::class, 'only_in_book_templates' ), 10, 4 );
add_filter( 'hooked_block_' . self::BLOCK, array( self::class, 'configure' ), 10, 5 );
}
/**
* Keeps lumen/local-bookshop only after the Post Content block of the
* single-lp_book template (theme file or user-edited copy).
*/
public static function only_in_book_templates( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
if ( ! in_array( self::BLOCK, $hooked_block_types, true ) ) {
return $hooked_block_types;
}
$is_book_template = $context instanceof \WP_Block_Template
&& 'wp_template' === $context->type
&& 'single-lp_book' === $context->slug;
if ( $is_book_template ) {
return $hooked_block_types;
}
return array_values( array_diff( $hooked_block_types, array( self::BLOCK ) ) );
}
/**
* Sets the attributes of the inserted block; null means already suppressed.
*/
public static function configure( $parsed_hooked_block, $hooked_block_type, $relative_position, $parsed_anchor_block, $context ) {
if ( null === $parsed_hooked_block ) {
return null;
}
$parsed_hooked_block['attrs']['campaign'] = 'single-book';
$parsed_hooked_block['attrs']['align'] = 'wide';
return $parsed_hooked_block;
}
}
hooked_block_types
hooked_block_types (6.4) runs once per anchor block and position, receives the list of block types hooked there, and
returns the list to insert. It can remove types (as above), or add types that have no blockHooks at all, including
core blocks; this is how a plugin hooks a block conditionally. Its fourth argument, $context, says what is being
processed:
$context |
When |
|---|---|
|
Templates and template parts, from a theme file or edited by the user. |
|
A |
|
A registered pattern (its |
Code that checks the context must test for each type positively. A check written for 6.4, such as "if the context is
not a WP_Block_Template, treat it as a pattern array", breaks from 6.5 on, when a WP_Post can arrive; use
is_array( $context ) for patterns. The anchor type argument can be null for freeform (classic) content.
hooked_block and hooked_block_{$hooked_block_type}
hooked_block and its dynamic variant hooked_block_{$hooked_block_type} (both 6.5) receive the parsed block that is
about to be inserted (blockName, attrs, innerBlocks, innerHTML, innerContent), the hooked type, the
position, the parsed anchor block and the context. They can:
-
set attributes, as
configure()does, or copy them from the anchor ($parsed_anchor_block['attrs']); -
replace the block with a wrapper: return a
core/groupwhoseinnerBlockscontains the hooked block, with matchinginnerContent, to give it a layout; -
return
nullto suppress this one insertion while keeping the others.
The dynamic name keeps the slash of the block name (hooked_block_lumen/local-bookshop). Every callback starts with
the null check, because an earlier callback may already have suppressed the block.
Keeping user edits: ignoredHookedBlocks
A hooked block must stay out of a template once the site owner removes it, but must still appear in templates edited
before the plugin was installed. WordPress records the decision in the anchor block’s own attributes. When the Site
Editor saves a template or template part, the rest_pre_insert_wp_template (or rest_pre_insert_wp_template_part) filter runs the
Block Hooks algorithm in "record" mode: for every anchor, it adds the types hooked there to
metadata.ignoredHookedBlocks. On every later load, a type listed there is not inserted at that anchor, whether the
block is still in the template, was moved, or was deleted.
The edits below go through the Site Editor’s own REST endpoints, run with wp eval-file --user=admin. The script
reads the template as the editor does, optionally deletes the hooked block, and saves it:
<?php
// Usage: wp eval-file save.php keep|remove --user=admin
$mode = $args[0] ?? 'keep';
$get = new WP_REST_Request( 'GET', '/wp/v2/templates/lumen//single-lp_book' );
$get->set_param( 'context', 'edit' );
$content = rest_do_request( $get )->get_data()['content']['raw'];
if ( 'remove' === $mode ) {
$content = preg_replace( '#<!-- wp:lumen/local-bookshop [^>]*/-->#', '', $content );
}
$post = new WP_REST_Request( 'POST', '/wp/v2/templates/lumen//single-lp_book' );
$post->set_param( 'content', $content );
echo rest_do_request( $post )->get_status(), "\n";
$stored = get_block_template( 'lumen//single-lp_book' );
echo $stored->source, "\n";
echo get_post( $stored->wp_id )->post_content, "\n";
Source: REST API Handbook: Templates
Before any edit, GET returns the theme file (source is theme) with the hooked block already in it, attributes set
by the filter. This is what the Site Editor displays:
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"}} /--><!-- wp:lumen/local-bookshop {"campaign":"single-book","align":"wide"} /-->
</main>
<!-- /wp:group -->
After wp eval-file save.php remove --user=admin (the administrator deleted the block, or switched off its toggle in
the Plugins panel), the template is stored as a wp_template post (source is custom) and the anchor carries the
decision:
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:post-title {"level":1} /-->
<!-- wp:post-content {"layout":{"type":"constrained"},"metadata":{"ignoredHookedBlocks":["lumen/local-bookshop"]}} /-->
</main>
<!-- /wp:group -->
The book page no longer contains wp-block-lumen-local-bookshop, and deactivating and reactivating the plugin does not
bring it back. Deleting the user’s copy of the template ("Reset" in the Site Editor, or DELETE on the same route with
force) returns to the theme file, and the block is inserted again.
| Other situation checked | Result |
|---|---|
The template was edited and saved while the plugin was inactive, then the plugin was activated |
No |
The template is saved with the block kept, |
The block is stored as a normal block and the anchor gets |
The template is saved with the block kept, |
The block is stored, but the anchor gets no metadata, because single-instance blocks already present are skipped before recording. The page shows one copy. If the owner later deletes the block, the next save records the metadata. |
For content whose anchor is the root of a post (Navigation menus, synced patterns, template parts and, from 6.8, any
post through core/post-content), there is no anchor block in the stored markup to carry the attribute. WordPress
stores the list in the _wp_ignored_hooked_blocks post meta instead, as a JSON array, and wraps the content in a
temporary core/navigation, core/block, core/template-part or core/post-content block that holds the metadata
while the algorithm runs. The meta is protected (leading underscore), so it is not editable as a custom field.
Navigation, template parts and post content
firstChild and lastChild are the positions that work with containers whose inner blocks live in another post.
For the Navigation block, the hooked block becomes the first or last item of the wp_navigation menu the block
references. A filter can hook any block there, for example the core Login/out block at the end of every menu:
<?php
add_filter(
'hooked_block_types',
static function ( $hooked_block_types, $relative_position, $anchor_block_type, $context ) {
if ( 'core/navigation' === $anchor_block_type && 'last_child' === $relative_position ) {
$hooked_block_types[] = 'core/loginout';
}
return $hooked_block_types;
},
10,
4
);
With a menu containing one "Books" link, GET /wp/v2/navigation/<id>?context=edit returns
<!-- wp:navigation-link {"label":"Books","url":"/books/","kind":"custom"} /--><!-- wp:loginout /-->. Saving the menu
without the Login/out block stores ["core\/loginout"] in the menu’s _wp_ignored_hooked_blocks meta, and the front
end then renders the menu without it.
Template parts behave the same way: firstChild/lastChild of core/template-part place a block at the start or end
of the part’s content (a header part, for instance), and the ignored list of an edited part is kept in its post meta.
Before and after positions on core/template-part are siblings in the template that uses the part.
For post content, the_content runs apply_block_hooks_to_content_from_post_object() at priority 8, before
do_blocks(), with the post as context. Only firstChild and lastChild of core/post-content apply there; before
and after are suppressed for the temporary wrapper, so lumen/local-bookshop is never inserted twice. In the post
editor, the REST API inserts hooked blocks and records ignored ones only for the post types in
rest_block_hooks_post_types (7.0, default post, page, wp_block, wp_navigation); a plugin that hooks into the
content of lp_book posts and wants editors to see the block in the book editor adds lp_book to that list.
Errors and pitfalls
-
The block does not appear. The anchor name must be exact (
core/post-content, with namespace) and the position one of the four camelCase values; neither mistake produces an error.get_hooked_blocks()shows what WordPress registered. Check also that the template was not saved with the type inignoredHookedBlocks. -
It appears everywhere.
blockHookshas no conditions; narrow it withhooked_block_typesand make the render function return nothing outside its context, asrender.phpdoes for non-book posts. -
Block validation errors in the editor. A static block inserted with empty
innerHTMLdoes not match itssave()output. Hook dynamic blocks, or build the full inner content in ahooked_blockfilter. -
Two copies. A block that must appear once per page should declare
"multiple": false. -
No Plugins toggle for a filter-added block. The toggle appears for such types only once the anchor lists them in
ignoredHookedBlocks, that is, after the layout has been saved. -
Performance. Every template, part, pattern and post content load runs the algorithm when any hook or
hooked_block_typesfilter exists; keep the filters cheap and avoid database queries in them.
Security
A hooked block’s render code is a site-wide output path. Escape all output as render.php does, never trust attribute
values (site owners can edit them), and keep per-user data out of templates served from a page cache. Anyone who can
edit templates (edit_theme_options) can move, restyle or remove the block, by design.
What older books and tutorials get wrong
The three older books cover WordPress 6.2 to 6.6 but never mention Block Hooks (6.4). To add output to every single
post they append HTML in a the_content filter or have the user edit theme files or widget areas; in a block theme
that HTML is invisible to the Site Editor and edited theme files are lost on update. A hooked block is the supported
alternative. Tutorials written for 6.4 also state that hooks never reach templates
the user has edited and that hooked blocks always have default attributes; both stopped being true in 6.5.