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

before

before

Sibling immediately before each anchor block.

after

after

Sibling immediately after each anchor block.

firstChild

first_child

First inner block of each anchor block (the anchor must accept inner blocks).

lastChild

last_child

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 wp_template/wp_template_part posts)

6.5

firstChild/lastChild of the Navigation block (menu stored as a wp_navigation post)

6.5

firstChild/lastChild of a Template Part block, by wrapping the part’s content in a temporary anchor

6.7

Synced patterns (wp_block posts, wrapper core/block)

6.8

Post content of any post type, as firstChild/lastChild of core/post-content, through the_content

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&#038;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

WP_Block_Template (type is wp_template or wp_template_part; slug, theme, source)

Templates and template parts, from a theme file or edited by the user.

WP_Post

A wp_navigation menu (6.5), a synced pattern wp_block (6.8), or a post whose content passes through the_content (6.8).

array

A registered pattern (its name, title, content and other properties).

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/group whose innerBlocks contains the hooked block, with matching innerContent, to give it a layout;

  • return null to 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";

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 ignoredHookedBlocks, so the block is inserted into the user’s template (the 6.5 behaviour; in 6.4 it was not).

The template is saved with the block kept, "multiple": true

The block is stored as a normal block and the anchor gets ignoredHookedBlocks; the page shows one copy.

The template is saved with the block kept, "multiple": false (as in Lumen)

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

  • It appears everywhere. blockHooks has no conditions; narrow it with hooked_block_types and make the render function return nothing outside its context, as render.php does for non-book posts.

  • Block validation errors in the editor. A static block inserted with empty innerHTML does not match its save() output. Hook dynamic blocks, or build the full inner content in a hooked_block filter.

  • 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_types filter 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.