Block Bindings API

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.

A block binding replaces the value of a block attribute with a value computed elsewhere: a custom field, the post’s permalink, a term’s name, or anything a plugin can look up. The block stays an ordinary core Paragraph, Heading, Image or Button, but its text or URL comes from the source when the page is rendered. Bindings are how a block theme shows custom fields without a custom block, and how synced patterns let each instance override some content. This page is for theme and block developers: it binds core blocks to the Lumen Press book meta, uses the four core sources, and builds the custom lumen/book-data source of the lumen-catalog plugin on the server and in the editor.

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. The HTML below comes from do_blocks() and front-end requests on a 7.1.3 site running lumen-catalog, and the editor script from a real wp-scripts build. WordPress 7.2, in beta and due in December 2026, adds one supported attribute so far.

What a binding is

Before bindings, a custom field reached a page through a shortcode, a PHP template (classic themes only) or a custom block. The Block Bindings API, added in WordPress 6.5, connects an attribute of a supported block to a source:

  • The binding is data in the block’s comment delimiter, under metadata.bindings. No JavaScript runs on the front end.

  • The source is a named PHP callback (register_block_bindings_source()); WP_Block calls it while rendering.

  • The editor shows the same value through a JavaScript half of the source (registerBlockBindingsSource(), 6.7), which can also write edits back.

Core ships four sources: core/post-meta, core/pattern-overrides, core/post-data and core/term-data. Themes and plugins add their own: on the Lumen site, wp eval 'print_r( array_keys( get_all_registered_block_bindings_sources() ) );' also lists lumen/book-data from lumen-catalog and twentytwentyfive/format (the post format name) from Twenty Twenty-Five.

Anatomy of a binding

The book facts group below belongs in the single-lp_book.html template of the lumen theme (see Block Theme Templates and Template Parts), and works the same in a pattern or in the book’s content. Each bound block keeps its normal markup, whose text is the fallback shown when the source returns nothing:

<!-- wp:group {"className":"lumen-book-facts","layout":{"type":"constrained"}} -->
<div class="wp-block-group lumen-book-facts">
<!-- wp:image {"sizeSlug":"medium","metadata":{"bindings":{"url":{"source":"lumen/book-data","args":{"field":"cover"}}}}} -->
<figure class="wp-block-image size-medium"><img alt=""/></figure>
<!-- /wp:image -->

<!-- wp:heading {"level":3,"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"lp_isbn"}}}}} -->
<h3 class="wp-block-heading">ISBN not assigned yet</h3>
<!-- /wp:heading -->

<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"lumen/book-data","args":{"field":"pages"}}}}} -->
<p>Page count</p>
<!-- /wp:paragraph -->

<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button {"metadata":{"bindings":{"url":{"source":"core/post-data","args":{"field":"link"}}}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">Share this book</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
</div>
<!-- /wp:group -->

Every block has a metadata attribute; its bindings map attribute names to a source and args (any JSON the source understands: key for post meta, field for the others). On "The Lighthouse Ledger" the group renders as:

<div class="wp-block-group lumen-book-facts has-global-padding is-layout-constrained wp-block-group-is-layout-constrained">
<figure class="wp-block-image size-medium"><img decoding="async" src="https://lumenpress.test/wp-content/uploads/2026/10/lighthouse-ledger-200x300.png" alt=""/></figure>
<h3 class="wp-block-heading">978-0-00-000001-9</h3>
<p class="wp-block-paragraph">312 pages</p>
<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a href="https://lumenpress.test/books/the-lighthouse-ledger/" class="wp-block-button__link wp-element-button">Share this book</a></div>
</div>
</div>

The stored markup keeps the fallbacks; values are computed on every render, so a changed ISBN shows everywhere at once.

How a binding is resolved

WP_Block::render() resolves bindings first (process_block_bindings()), then writes the values with the HTML API:

flowchart TD A["WP_Block::render()"] --> B{"metadata.bindings set and the block type has supported attributes?"} B -- no --> R["Saved HTML unchanged"] B -- yes --> D["For each bound attribute (__default expanded for pattern overrides)"] D --> E{"Attribute supported, source registered?"} E -- no --> D E -- yes --> F["Copy the source's uses_context (postId, postType...) into the block context"] F --> G["get_value_callback(args, block, attribute), then filter block_bindings_source_value"] G --> I{"null?"} I -- yes --> D I -- no --> K{"Attribute source in block.json"} K -- "rich-text / html" --> L["Replace the element's inner HTML, filtered by wp_kses_post()"] K -- attribute --> M["set_attribute() on the selector's tag: img src, a href"] K -- "none (dynamic block)" --> N["Passed to render_callback in attributes"]
  • null keeps the fallback; any other value, even an empty string, replaces it. The Image above disappears for a book without a cover, because the Image block renders nothing without a src.

  • Context comes from the source. WordPress copies the source’s uses_context keys (postId, postType; termId, taxonomy) into the bound block, although a Paragraph’s block.json does not ask for them. The post is the main query’s on a single page, the current item in a Query Loop.

  • Unsupported attributes and unknown sources are skipped silently, and the fallback stays.

  • Replacement follows the attribute’s source in block.json. Rich-text and HTML attributes replace the inner HTML of the selector element (since 7.1 without touching nested inner blocks, which made List Item bindable); attribute sources set one HTML attribute; an attribute with no source (typical of dynamic blocks) only reaches the render_callback.

Supported blocks and attributes

get_block_bindings_supported_attributes() (6.9) returns, for a block type, the attributes that accept bindings. In 7.1.3 the core list is:

Block Attributes Since

core/paragraph

content

6.5

core/heading

content

6.5

core/image

id, url, title, alt; caption

6.5; 6.9

core/button

url, text, linkTarget, rel

6.5

core/post-date

datetime

6.9

core/navigation-link, core/navigation-submenu

url

6.9

core/list-item

content

7.1

The 7.2 betas add core/icon with its icon attribute; until 7.2 ships that is upcoming. Two filters (both 6.9) change the list: block_bindings_supported_attributes receives the attributes and the block type name for every block, and the dynamic block_bindings_supported_attributes_{$block_type} receives only the attributes of one block type. The result is used for rendering, for pattern overrides (a __default binding expands to it), and in the editor, which gets it as the __experimentalBlockBindingsSupportedAttributes editor setting.

The filter is how a plugin makes its own block bindable. lumen/latest-books is a dynamic block whose genre attribute limits the list to one genre (see Static and Dynamic Blocks). Declaring genre bindable (the latest_books_attributes() method of the Bindings class below) lets the book template show "more books in this genre" without any new block code:

<!-- wp:lumen/latest-books {"count":3,"showDate":false,"metadata":{"bindings":{"genre":{"source":"lumen/book-data","args":{"field":"genre"}}}}} /-->

On "The Lighthouse Ledger" (genre mystery) the block lists the other two mystery books; with the filter removed the binding is ignored and "Paper Moons", a literary fiction title, appears too. A static block’s attribute is bindable only when its source is rich-text, html or attribute with a plain tag name as selector: the HTML replacement does not support CSS selectors yet.

The core sources

core/post-meta

Binds to a meta value of the post in context, with args.key. The callback returns null (fallback kept) unless all of these hold:

  • the post is publicly viewable, or the current user can read_post it, and it is not password-protected;

  • the key is not protected: is_protected_meta() is false, so keys starting with _ are never bound;

  • the key is registered for the post type (or for all post types) with show_in_rest.

The Lumen book meta (lp_isbn, lp_pages, lp_published_on) is registered with show_in_rest, a label and a default in Metadata and Custom Fields, so it qualifies. The stock-sync timestamp _lp_stock_synced_at is protected and does not, even though it is in REST output:

<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"lp_pages"}}}}} -->
<p>Pages</p>
<!-- /wp:paragraph -->

<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"_lp_stock_synced_at"}}}}} -->
<p>Stock date not public</p>
<!-- /wp:paragraph -->

The first renders 312, the raw value, not "312 pages"; the second keeps "Stock date not public". Formatting and translation belong in a custom source, which is why lumen/book-data exists. The Attributes panel only offers fields whose type matches the attribute, so the integer lp_pages is not listed for a Paragraph’s string content, although a binding typed in the code editor renders.

core/post-data

Added in 6.9, with args.field set to date (ISO 8601), modified (only when later than the publication date, else an empty string) or link (the permalink). The Share button above uses link. Since 6.9 the Post Date block has a datetime attribute that it binds to core/post-data itself, and the Navigation Link and Submenu blocks bind url to post or term data using their own id attribute instead of context, so a menu item follows a page whose slug changes. The Attributes panel is not shown for these three blocks.

core/term-data

Added in 6.9, with args.field set to id, name, slug, description (filtered with wp_kses_post()), parent, count or link. It needs termId and taxonomy in context, which the Term Template block inside a Terms Query block (also 6.9) provides for each term. A genre index for the book archive:

<!-- wp:terms-query {"termQuery":{"perPage":10,"taxonomy":"lp_genre","order":"asc","orderBy":"name","include":[],"hideEmpty":true,"showNested":false,"inherit":false}} -->
<div class="wp-block-terms-query"><!-- wp:term-template -->
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/term-data","args":{"field":"name"}}}}} -->
<p>Genre name</p>
<!-- /wp:paragraph -->

<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button {"metadata":{"bindings":{"url":{"source":"core/term-data","args":{"field":"link"}},"text":{"source":"core/term-data","args":{"field":"count"}}}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button">0</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->
<!-- /wp:term-template --></div>
<!-- /wp:terms-query -->

On the book archive this renders one list item per genre, such as "Mystery" with a button reading 3 linked to https://lumenpress.test/books/genre/mystery/. For a taxonomy that is not publicly queryable the source returns values only to logged-in users with read.

core/pattern-overrides

Synced patterns (wp_block posts, inserted as core/block) are identical everywhere they are used. Overrides (6.6; the source is registered since 6.5) let each instance change selected blocks. A block in the pattern opts in with a metadata.name and a __default binding to core/pattern-overrides, which WordPress expands to every supported attribute of that block; the instance stores its values in the content attribute of core/block, keyed by name:

<!-- wp:block {"ref":9,"content":{"Book title":{"content":"The Glass Orchard"},"Blurb":{"content":"An orchard that remembers every visitor."}}} /-->

The source reads the pattern/overrides context that core/block provides and returns the value stored for the block’s name and attribute, or null so the pattern’s own text shows. Renaming a block in the pattern orphans the values stored under the old name. Overridable patterns are built in Patterns for Site Builders and Theme Patterns.

A custom source in PHP

register_block_bindings_source( $source_name, $source_properties ) registers a source on init. 7.1.3 accepts exactly three properties: label (required, shown in the editor), get_value_callback (required; receives $source_args, the WP_Block instance and the attribute name, and returns the value or null) and uses_context (optional, the context keys the callback reads).

lumen/book-data turns the book’s data into display-ready strings: the ISBN, a translated page count, the first genre’s slug and the cover URL. The class lives in src/Bindings.php of lumen-catalog, is autoloaded by Composer, and Plugin::boot() calls Bindings::boot(); next to Blocks::boot();:

<?php
/**
 * The lumen/book-data block bindings source.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Exposes formatted book data to core blocks through the Block Bindings API.
 */
final class Bindings {

	const SOURCE = 'lumen/book-data';

	// Called from Plugin::boot().
	public static function boot(): void {
		add_action( 'init', array( self::class, 'register_source' ) );
		add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_source' ) );
		add_filter( 'block_bindings_supported_attributes_lumen/latest-books', array( self::class, 'latest_books_attributes' ) );
		add_filter( 'block_bindings_source_value', array( self::class, 'keep_fallback_for_empty_meta' ), 10, 5 );
	}

	// The editor receives the name, label and context of a server source automatically.
	public static function register_source(): void {
		register_block_bindings_source(
			self::SOURCE,
			array(
				'label'              => __( 'Book data', 'lumen-catalog' ),
				'get_value_callback' => array( self::class, 'get_value' ),
				'uses_context'       => array( 'postId', 'postType' ),
			)
		);
	}

	// Returns one book field for $source_args['field'], or null to keep the block's own content.
	public static function get_value( array $source_args, $block_instance, string $attribute_name ) {
		$post_id = (int) ( $block_instance->context['postId'] ?? 0 );
		$post    = $post_id ? get_post( $post_id ) : null;

		if ( ! $post || 'lp_book' !== $post->post_type ) {
			return null;
		}

		// Same visibility rule as the core sources: no data from drafts or protected posts for visitors.
		if ( ( ! is_post_publicly_viewable( $post ) && ! current_user_can( 'read_post', $post_id ) ) || post_password_required( $post ) ) {
			return null;
		}

		switch ( $source_args['field'] ?? '' ) {
			case 'isbn':
				$isbn = (string) get_post_meta( $post_id, 'lp_isbn', true );
				return '' === $isbn ? null : $isbn;

			case 'pages':
				$pages = (int) get_post_meta( $post_id, 'lp_pages', true );
				/* translators: %d: number of pages. */
				return $pages ? sprintf( _n( '%d page', '%d pages', $pages, 'lumen-catalog' ), $pages ) : null;

			case 'genre':
				$terms = get_the_terms( $post_id, 'lp_genre' );
				return is_array( $terms ) ? $terms[0]->slug : null;

			case 'cover':
				$url = get_the_post_thumbnail_url( $post_id, 'medium' );
				return $url ? esc_url_raw( $url ) : null;
		}

		return null;
	}

	// Lets the dynamic lumen/latest-books block bind its "genre" attribute (WordPress 6.9+).
	public static function latest_books_attributes( array $attributes ): array {
		$attributes[] = 'genre';
		return $attributes;
	}

	// core/post-meta returns '' for an unset book field, which would blank the block; null keeps its own text.
	public static function keep_fallback_for_empty_meta( $value, $name, $source_args, $block_instance, $attribute_name ) {
		$is_book_meta = 'core/post-meta' === $name && 0 === strpos( $source_args['key'] ?? '', 'lp_' );

		return ( $is_book_meta && 'content' === $attribute_name && '' === $value ) ? null : $value;
	}

	// Loads the editor half of the source, built by wp-scripts into build/editor/.
	public static function enqueue_editor_source(): void {
		$asset = require dirname( LUMEN_CATALOG_FILE ) . '/build/editor/book-data.asset.php';

		wp_enqueue_script(
			'lumen-catalog-book-data',
			plugins_url( 'build/editor/book-data.js', LUMEN_CATALOG_FILE ),
			$asset['dependencies'],
			$asset['version'],
			true
		);
		wp_set_script_translations( 'lumen-catalog-book-data', 'lumen-catalog' );
	}
}

Points worth noting in the class:

  • Visibility is the source’s job. The check copied from core/post-meta keeps a draft’s data off public pages: a visitor sees the fallbacks, maria.editor the values.

  • block_bindings_source_value (6.7) filters the value of any source. core/post-meta returns the registered default '' for a book without an ISBN, and an empty string replaces the fallback: without keep_fallback_for_empty_meta() the heading renders as an empty <h3> on "The Glass Orchard"; with it, "ISBN not assigned yet".

  • Registration errors are _doing_it_wrong() notices, and the function returns false: names with uppercase letters or no namespace, a name already registered, a missing label, or invalid properties (any other key).

  • unregister_block_bindings_source(), get_block_bindings_source() and get_all_registered_block_bindings_sources() complete the PHP API (all 6.5).

The source in the editor

The editor must show, and possibly edit, bound values from unsaved data. Since 6.7 every server source is registered in the editor automatically (name, label, usesContext), so a bound block shows the source label. Real values need a JavaScript half, registered with registerBlockBindingsSource() from @wordpress/blocks:

Property Since Meaning

name

6.7

The same name as on the server.

label

6.7

Optional when the server registered one; a different label overrides it with a console warning.

usesContext

6.7

Extra editor context, merged with the server’s uses_context.

getValues( { select, clientId, context, bindings } )

6.7

Returns { attribute: value } for the block’s bindings to this source.

setValues( { select, clientId, dispatch, context, bindings } )

6.7

Called when the user edits a bound value; each binding carries args and newValue.

canUserEditValue( { select, context, args } )

6.7

Whether the bound value is editable in place. Ignored unless setValues exists; default is read-only.

getFieldsList( { select, context } )

6.9

Fields offered in the Attributes panel: objects with label, type and args.

src/editor/book-data.js mirrors Bindings::get_value() with the editor’s data stores. It reads the edited book record, so the page count updates as the user types in a meta field, and it lets users edit the raw ISBN in place:

/**
 * Editor half of the lumen/book-data block bindings source.
 */
import { registerBlockBindingsSource } from '@wordpress/blocks';
import { store as coreDataStore } from '@wordpress/core-data';
import { __, _n, sprintf } from '@wordpress/i18n';

const FIELDS = [
	{ label: __( 'ISBN-13', 'lumen-catalog' ), type: 'string', args: { field: 'isbn' } },
	{ label: __( 'Page count', 'lumen-catalog' ), type: 'string', args: { field: 'pages' } },
	{ label: __( 'Genre slug', 'lumen-catalog' ), type: 'string', args: { field: 'genre' } },
	{ label: __( 'Cover URL', 'lumen-catalog' ), type: 'string', args: { field: 'cover' } },
];

const isBook = ( context ) => context?.postType === 'lp_book' && !! context?.postId;

// Mirrors Bindings::get_value() in PHP, reading the unsaved edits of the book.
function readField( select, context, field ) {
	const data = select( coreDataStore );
	const book = data.getEditedEntityRecord( 'postType', 'lp_book', context.postId );
	const meta = book?.meta ?? {};

	switch ( field ) {
		case 'isbn':
			return meta.lp_isbn || null;
		case 'pages':
			return meta.lp_pages
				? sprintf(
						/* translators: %d: number of pages. */
						_n( '%d page', '%d pages', meta.lp_pages, 'lumen-catalog' ),
						meta.lp_pages
					)
				: null;
		case 'genre': {
			const termId = book?.lp_genre?.[ 0 ];
			return termId ? data.getEntityRecord( 'taxonomy', 'lp_genre', termId )?.slug ?? null : null;
		}
		case 'cover': {
			const media = book?.featured_media && data.getEntityRecord( 'postType', 'attachment', book.featured_media );
			return media?.media_details?.sizes?.medium?.source_url ?? media?.source_url ?? null;
		}
	}
	return null;
}

registerBlockBindingsSource( {
	name: 'lumen/book-data',
	label: __( 'Book data', 'lumen-catalog' ),

	getValues( { select, context, bindings } ) {
		const values = {};
		for ( const [ attribute, { args } ] of Object.entries( bindings ) ) {
			const field = FIELDS.find( ( item ) => item.args.field === args?.field );
			const value = isBook( context ) ? readField( select, context, args?.field ) : null;
			// Outside a book (a template, a pattern preview) show the field's label as a placeholder.
			values[ attribute ] = value ?? field?.label ?? args?.field;
		}
		return values;
	},

	setValues( { dispatch, context, bindings } ) {
		const meta = {};
		for ( const { args, newValue } of Object.values( bindings ) ) {
			if ( args?.field === 'isbn' ) {
				meta.lp_isbn = newValue;
			}
		}
		dispatch( coreDataStore ).editEntityRecord( 'postType', context.postType, context.postId, { meta } );
	},

	// Only the raw ISBN is editable, only on the book itself, never inside a Query Loop.
	canUserEditValue( { select, context, args } ) {
		if ( args?.field !== 'isbn' || ! isBook( context ) || context?.query || context?.queryId ) {
			return false;
		}
		return !! select( coreDataStore ).canUser( 'update', {
			kind: 'postType',
			name: 'lp_book',
			id: context.postId,
		} );
	},

	getFieldsList( { context } ) {
		return context?.postType === 'lp_book' ? FIELDS : [];
	},
} );

After wp-scripts format rewraps the long lines, the file passes npm run lint:js. setValues only stages the edit: it is saved with the post through the REST API, where the auth_callback of lp_isbn (Editors and above) still decides, so an Author’s ISBN edit is rejected on save.

Building and loading the script

The script is not a block, so it is built as a separate entry next to the blocks of block.json and Registration. The editor packages are listed as dependencies so the linter can resolve the imports; webpack still loads them from WordPress:

{
	"name": "lumen-catalog",
	"version": "0.1.0",
	"private": true,
	"scripts": {
		"build": "npm run build:blocks && npm run build:editor",
		"build:blocks": "wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules",
		"build:editor": "wp-scripts build book-data.js --source-path=src/editor --output-path=build/editor",
		"lint:js": "wp-scripts lint-js src/editor"
	},
	"dependencies": {
		"@wordpress/blocks": "17.0.0",
		"@wordpress/core-data": "9.0.0",
		"@wordpress/i18n": "7.0.0"
	},
	"devDependencies": {
		"@wordpress/scripts": "37.0.0"
	}
}

An entry passed on the command line is resolved relative to --source-path; without --source-path=src/editor the build also copies the blocks' block.json files from the default src/ into build/editor/. npm run build writes build/editor/book-data.js and book-data.asset.php, whose dependency list (wp-blocks, wp-core-data, wp-i18n) and content hash enqueue_editor_source() passes to wp_enqueue_script().

enqueue_block_editor_assets loads it in the post and Site Editors after wp-blocks has bootstrapped the server sources, so the client registration completes the bootstrapped one.

Binding in the editor

Users create bindings in the Attributes panel of the block settings sidebar (6.7). Since 6.9 each bindable attribute can be connected to a field of any source with getFieldsList() and disconnected with one click. Only fields whose type matches the attribute are listed (rich-text counts as string). core/post-meta lists the registered, non-protected meta of the post type by its label (6.7), also used as the placeholder for an empty value.

Who may change bindings is controlled by the edit_block_binding meta capability (6.7): in the post editor it maps to editing the post, in the Site Editor to edit_theme_options. The result reaches the editor as the canUpdateBlockBindings setting, which block_editor_settings_all can override. For a book written by the admin, get_block_editor_settings() returns true for admin and maria.editor and false for tom.author.

Editing a value is up to the source’s canUserEditValue. For core/post-meta the value is editable when the key is registered for the post type, the user can update the post, the block is not in a Query Loop, and the classic Custom Fields panel is off in the editor preferences.

Version history

Version Change

6.5

Block Bindings API: register_block_bindings_source() and the registry functions, core/post-meta and core/pattern-overrides sources; Paragraph, Heading, Image (id, url, title, alt) and Button. Bindings edited only in the code editor.

6.6

Pattern overrides in synced patterns, with metadata.name and the __default binding.

6.7

Editor API: registerBlockBindingsSource(), unregisterBlockBindingsSource(), getBlockBindingsSource(), getBlockBindingsSources(), useBlockBindingsUtils(); server sources bootstrapped into the editor; Attributes panel for post meta; editing meta values in place; meta label; block_bindings_source_value filter; edit_block_binding capability and canUpdateBlockBindings setting.

6.9

core/post-data and core/term-data; getFieldsList(); source switching in the Attributes panel; get_block_bindings_supported_attributes() with the block_bindings_supported_attributes and block_bindings_supported_attributes_{$block_type} filters; Image caption, Post Date datetime, Navigation Link and Submenu url.

7.1

List Item content; rich-text bindings no longer overwrite nested inner blocks.

7.2 (beta)

Icon block icon attribute in the supported list; upcoming.

Errors and pitfalls

Symptom Cause and fix

The fallback text shows instead of the meta value

The key is not registered with show_in_rest, is registered for another post type, starts with _, or the post is not public for this visitor. Check with wp post meta get and get_registered_meta_keys( 'post', 'lp_book' ).

A bound block is empty

The source returned an empty string (an unset meta with a '' default). Return null, or filter with block_bindings_source_value, to keep the fallback.

Binding ignored on a custom block

The attribute is not in the supported list (add it with the 6.9 filter), or its selector is a CSS selector rather than a tag name.

Meta field missing from the Attributes panel

Its type does not match the attribute (integer meta on a string attribute), it is protected, or it lacks show_in_rest.

Bound value is read-only in the editor

No setValues/canUserEditValue on the source, the user cannot update the post, the block is in a Query Loop, or the Custom Fields panel is on.

Custom source shows only its label in the editor

Only the server half is registered; add the JavaScript getValues.

Block bindings source "…" is already registered. in the console

registerBlockBindingsSource() ran twice with functions; enqueue the script once.

A bound value ignores unsaved edits

getValues reads getEntityRecord (saved data) instead of getEditedEntityRecord.

Security

A source runs on every render with the visitor’s permissions and decides what leaves the database: check visibility (is_post_publicly_viewable(), read_post, post_password_required()) as the core sources do. core/post-meta adds no exposure beyond show_in_rest, but a custom source can read anything, protected keys included. Output is filtered (wp_kses_post() for rich text, HTML API escaping for attributes), yet URLs still go through esc_url_raw() so no javascript: URL is bound to a link. On the write side, the meta auth_callback and sanitize_callback protect the data, not canUserEditValue. More in Security for Developers.

What older books and tutorials get wrong

None of the three older books (WordPress All-in-One For Dummies, Pro WordPress, Ultimate WordPress Handbook) covers Block Bindings, which arrived after their WordPress versions (6.3–6.6). Their ways of showing custom fields still work but are no longer the first choice for block themes:

Older advice Current practice

Show a custom field with a shortcode, or with get_post_meta() in a PHP template

Bind a core block to core/post-meta (or a custom source) in the block template.

A block attribute with "source": "meta" to edit post meta

Deprecated; use a binding, or useEntityProp inside a custom block.

A custom block for every field to display

One custom bindings source, used by core blocks.

"Reusable blocks" that cannot differ between uses

Synced patterns (6.3) with overrides (6.6).