If you’ve registered a register_block_bindings_source() callback and watched it work flawlessly on the front end while the block editor shows nothing but placeholder text, you’ve run into the most common piece of Block Bindings folklore: “it only works with post meta.” That claim was accurate as a description of the WordPress 6.7 Attributes panel. It has never been accurate as a description of the underlying API, and since WordPress 6.9 it isn’t even accurate for the editor UI anymore.
This guide separates the two. First, what the Block Bindings API has always been able to do at the render layer — bind any core block attribute to any registered PHP callback, including site options and third-party APIs. Second, what changed release by release to close the editor-experience gap. Third, two complete, production-shaped implementations: a global-options adapter and a cached external-API adapter, each with the server and client halves that a real integration needs.
- The Block Bindings API was never architecturally restricted to post meta —
register_block_bindings_source()has accepted custom callbacks pointing at any data source since WordPress 6.5 (register_block_bindings_source() function reference). - The real limitation was the WordPress 6.7 Attributes panel, which could create bindings only to
core/post-meta; custom sources had to be wired up through the Code Editor (Block Bindings: Improvements to the Editor Experience in 6.7). - WordPress 6.9 closed the discovery gap with
getFieldsList(), letting custom sources appear in the Attributes dropdown alongside post meta (Block Bindings improvements in WordPress 6.9). - A production-grade custom source needs four things: an allowlisted server callback, a matching client registration for editor preview, caching and timeouts for anything that leaves the request, and explicit permission gating for anything editable.
What the Block Bindings API Actually Supports
WordPress core ships exactly four built-in server sources today: core/post-meta, core/post-data, core/term-data, and core/pattern-overrides (Bindings – Block Editor Handbook). None of that is an architectural ceiling. The registration function’s own reference documentation lists custom database tables and external APIs as valid lookup targets, and the current handbook’s motivating example for a custom source is a function that returns random images from an external API — not a meta-field wrapper (register_block_bindings_source() function reference).
That flat line is why “meta-only” became the working assumption among plugin authors — not because the render engine enforced it, but because for four releases nothing in core visibly contradicted it.
Where the ceiling actually is
Bindability is gated separately from source registration, and this part is a real constraint. A block attribute has to participate in the Block Bindings support contract before any source — built-in or custom — can target it. Core’s default list covers selected attributes on the Image, Heading, Paragraph, Button, Navigation Link, Navigation Submenu, and Post Date blocks (Bindings – Block Editor Handbook). One plugin author reported hardcoding that list themselves because there was no way to discover it programmatically — a limitation core maintainers acknowledged directly and later addressed with supported-attribute filters in WordPress 6.9, extended to custom blocks and Pattern Overrides in WordPress 7.0 (Gutenberg issue #64756; Pattern Overrides in WP 7.0).
Why the Confusion: Editor UI, Not the Render Engine
WordPress 6.7’s own dev note is unambiguous about what shipped and what didn’t: the new Attributes panel “will not allow users to bind attributes to custom sources just yet… Bindings to custom sources, which can be added via the code editor or other programmatic means, will still display in the panel — they just can’t be connected via the UI for the moment” (Block Bindings: Improvements to the Editor Experience in 6.7). That sentence is the entire origin of the “meta-only” reputation. The 6.7 tracking issue collected 27 comments specifically about unfinished custom-source extensibility (Gutenberg issue #63018), and a separate roadmap issue listed “Site data” and “User data” sources as explicitly unshipped work (Gutenberg issue #60954).
A second, compounding gap: registering a source only in PHP gives you correct front-end rendering but no live editor preview. WordPress bootstraps the source’s name and label into the editor automatically, but without a client-side getValues() implementation the canvas shows a placeholder, not the resolved value (Block Bindings: Improvements to the Editor Experience in 6.7). This is documented, deliberate architecture, not a bug — official guidance splits server rendering (get_value_callback) from editor behavior (getValues, setValues, canUserEditValue, and since 6.9, getFieldsList) as two separate contracts a source can implement independently (Getting and setting Block Binding values in the Editor).
So the accurate diagnosis isn’t “escape post meta.” It’s: build a source that satisfies both contracts, because the render path and the editor path never share code.
The Dual-Registration Architecture
Every durable custom source is really two registrations under one shared name — a PHP registration that resolves the value at render time, and a JavaScript registration that resolves a preview value in the editor and optionally allows edits.
| Side | Registered via | Responsibilities |
|---|---|---|
| Server (PHP) | register_block_bindings_source(), called on init | Resolves the authoritative value during render via get_value_callback; can read uses_context (e.g. postId); output can be intercepted by the block_bindings_source_value filter |
| Client (JS) | registerBlockBindingsSource() from @wordpress/blocks | getValues() computes the editor preview; setValues() writes canvas edits back; canUserEditValue() gates who can edit (defaults to read-only); getFieldsList() (WP 6.9+) populates the Attributes dropdown |
A PHP-only registration is a completely legitimate, supported configuration for read-only, display-only data — the official walkthrough states plainly that third-party read-only data does not require setValues() (Getting and setting Block Binding values in the Editor). You only need the client half when editors should see a live preview, edit the value in place, or find the field in the Attributes selector.
Solution 1: Binding to Global Site Options
The most requested non-meta use case is straightforward: a support phone number, a promo banner, an announcement string — one value stored once via get_option() instead of duplicated across every post’s meta. There’s no built-in core/site-data source for this as of WordPress 6.9 (Bindings – Block Editor Handbook), so the adapter is yours to write.
The non-negotiable rule: never let source_args pick an arbitrary option key. get_option() will happily return a secret, a serialized array, or anything else stored under any key you allow — allowlist explicitly.
add_action( 'init', function () {
register_block_bindings_source(
'acme/site-option',
array(
'label' => __( 'Site settings', 'acme' ),
'get_value_callback' => function ( $args ) {
$allowed = array( 'support_phone', 'support_email' );
$key = isset( $args['key'] ) ? sanitize_key( $args['key'] ) : '';
if ( ! in_array( $key, $allowed, true ) ) {
return null;
}
$options = (array) get_option( 'acme_site_settings', array() );
return isset( $options[ $key ] )
? sanitize_text_field( $options[ $key ] )
: null;
},
)
);
} );The allowlist is the whole security model here: get_option() can return mixed types, so the callback normalizes the selected field to a bindable scalar before returning it (get_option() function reference; Sanitizing Data – Common APIs Handbook).
For the editor half, register a matching client source with getFieldsList() so the field shows up in the Attributes selector on WordPress 6.9+:
import { registerBlockBindingsSource } from '@wordpress/blocks';
import { store as coreDataStore } from '@wordpress/core-data';
registerBlockBindingsSource( {
name: 'acme/site-option',
getFieldsList() {
return [
{ label: 'Support phone', type: 'string', args: { key: 'support_phone' } },
{ label: 'Support email', type: 'string', args: { key: 'support_email' } },
];
},
getValues( { select, bindings } ) {
const settings = select( coreDataStore )
.getEntityRecord( 'root', 'site', undefined ) || {};
return Object.fromEntries(
Object.entries( bindings ).map( ( [ attribute, binding ] ) => [
attribute,
settings[ binding.args.key ] ?? '',
] )
);
},
canUserEditValue() {
return false;
},
} );Two details matter beyond copy-pasting this. First, getFieldsList()’s type for each field must match the target block attribute’s type exactly, or WordPress silently omits it from the selector (Block Bindings improvements in WordPress 6.9). Second, getValues() must return an object keyed by block attribute name, not by your source’s argument name — mixing those up is a common source of a preview that never updates.
Watch out returning false from canUserEditValue() (as above) is the correct default for most global-options sources. It makes the value visible and bindable but not editable from the canvas — safer than accidentally exposing a write path to a setting most editors shouldn’t touch.
Solution 2: Binding to an External REST API
The handbook’s own flagship custom-source example is an external API (register_block_bindings_source() function reference), but the naive version — calling wp_remote_get() directly inside get_value_callback — creates a real production hazard. The callback runs synchronously on every render of every bound block, and WordPress HTTP requests can return a WP_Error or a non-2xx response; do that uncached and you’ve coupled your page-load latency to a third party’s uptime (wp_remote_get() function reference).
The code below is the baseline version — cache, fetch, store. The lock-transient step shown in the diagram is a concurrency hardening layer covered right after the snippet; add it once you’ve confirmed the basic adapter works.
function acme_get_remote_catalog(): ?array {
$cached = get_transient( 'acme_catalog_v1' );
if ( is_array( $cached ) ) {
return $cached;
}
$response = wp_remote_get(
'https://api.example.com/v1/catalog/featured',
array( 'timeout' => 3 )
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $decoded ) ) {
return null;
}
$data = array(
'title' => sanitize_text_field( $decoded['title'] ?? '' ),
'image_url' => esc_url_raw( $decoded['image_url'] ?? '' ),
'cta_url' => esc_url_raw( $decoded['cta_url'] ?? '' ),
);
set_transient( 'acme_catalog_v1', $data, 5 * MINUTE_IN_SECONDS );
return $data;
}
add_action( 'init', function () {
register_block_bindings_source(
'acme/remote-catalog',
array(
'label' => __( 'Remote catalog', 'acme' ),
'get_value_callback' => function ( $args ) {
$allowed = array( 'title', 'image_url', 'cta_url' );
$field = isset( $args['field'] ) ? sanitize_key( $args['field'] ) : '';
if ( ! in_array( $field, $allowed, true ) ) {
return null;
}
$data = acme_get_remote_catalog();
return $data[ $field ] ?? null;
},
)
);
} );Three things this pattern gets right that a first draft usually misses:
- The remote payload is untrusted, full stop, even from a known vendor. WordPress security guidance requires validation on input and context-appropriate escaping on output regardless of source (Security – Common APIs Handbook; Escaping Data – Common APIs Handbook). Note the field-specific handling above —
esc_url_raw()for URLs,sanitize_text_field()for text — rather than one blanket sanitizer for every field. get_transient()returningfalsedoesn’t mean “fetch now, no matter what.” Under real concurrency, every simultaneous request sees a cache miss at once. A short-lived lock transient (or an atomicwp_cache_*operation if you’re on an object cache) ensures one request refreshes the value while the rest serve stale data or a fallback, rather than all of them hammering the upstream API simultaneously.- If the URL is ever built from user input or block attributes, use
wp_safe_remote_get()instead ofwp_remote_get()— it validates the resolved URL and redirect targets against SSRF, which a hardcoded base URL doesn’t need but a dynamic one absolutely does (wp_safe_remote_get() function reference).
The Transients API’s expiration is a maximum, not a guarantee — WordPress can evict a transient early under memory pressure (Transients – Common APIs Handbook), so null-safe fallback handling in the callback isn’t optional polish, it’s the difference between a stale price and a fatal error on the front end.
For sites with meaningful traffic, an even better pattern moves the fetch off the render path entirely: sync the remote value into a local option on a wp_cron or Action Scheduler cadence, and let get_value_callback do a fast local read with no HTTP call in the request lifecycle at all.
Making Custom Sources Discoverable and Editable
Before WordPress 6.9, there was no programmatic way for a custom source to appear in the Attributes panel dropdown — the same hardcoded-attribute-list problem noted earlier (Gutenberg issue #64756) applied equally to field discovery. getFieldsList() closes that gap:
getFieldsList() {
return [
{ label: 'Active Server Nodes', type: 'string', args: { metric_key: 'active_server_nodes' } },
{ label: 'System Memory Usage (%)', type: 'string', args: { metric_key: 'memory_usage_pct' } },
];
}If you need to support WordPress versions before 6.9, or want a more curated editing experience than a raw field dropdown, ship a block variation via get_block_type_variations instead — this pre-bakes the binding into a named, insertable block so editors never see raw source arguments at all. It’s the same approach the community used for ACF-style integrations before official support landed.
Editability is a second, independent gate. canUserEditValue() defaults to false — bound attributes are read-only unless a source explicitly opts in (Bindings – Block Editor Handbook). Treat that default as correct for most sources rather than an obstacle to work around: for global options and third-party data, the safest configuration is read-only in the editor, with any writes routed through a dedicated, authenticated REST endpoint that runs its own permission_callback rather than through the binding’s setValues() (Adding Custom Endpoints – REST API Handbook).
Watch out canUpdateBlockBindings (who may create or modify a binding connection) and canUserEditValue (who may edit the bound value) are two different authorization surfaces controlled by two different mechanisms. Conflating them is how a plugin ends up letting a content editor overwrite a value they should never see, let alone touch.
The Security Model – Why It’s Opt-In by Design
The restrictiveness developers run into isn’t an oversight; it’s a deliberate response to a near-miss. Before WordPress 6.5 shipped, Gutenberg pull request #59326 added two checks specifically so bindings “don’t leak private post meta”: verifying the meta field is registered as non-protected, and verifying it’s exposed through the REST API. In the PR author’s words, “it seems safer to add these limitations to ensure no unwanted data is leaked.” That change is the direct reason Advanced Custom Fields and Pods weren’t bindable out of the box at launch — their field storage didn’t meet the new bar, and each had to build its own integration. ACF’s response, documented in its own changelog starting with version 6.3.6, was a per-field “Allow Access to Value in Editor UI” toggle, with guidance to disable editor access to any field “especially if that field contains information that is intended to be secure, such as access keys.”.
That posture generalizes directly to every custom source in this guide: allowlist which keys or fields are bindable, escape output per attribute context rather than trusting one blanket filter, and gate any write path behind a real capability check — current_user_can( 'manage_options' ), not merely “is logged in.” Core’s own wp_kses_post() pass before render is sanitization aimed at rich-text content, not context-specific escaping; it doesn’t substitute for esc_url() on a URL attribute or esc_attr() on an attribute value.
Conclusion
“Block Bindings only works with post meta” was an accurate complaint about one release’s editor UI and an inaccurate description of the API underneath it from day one. The render engine has been source-agnostic since WordPress 6.5; what changed through 6.7, 6.9, and 7.0 was how much of that capability the editor surfaced without custom code — first a public JavaScript registration API, then dropdown discovery via getFieldsList(), then broader attribute and custom-block support.
For your next custom source, the checklist is short: register the server callback with an explicit allowlist, add the client registration only if editors need to see or edit the value in the canvas, cache anything that leaves the request, and default every new field to read-only until you’ve deliberately decided otherwise.
FAQs
Related Citations & References
- WIBlock Bindings API – WordPress Deep Dive
- MANew Feature: The Block Bindings API – Make WordPress Core
- FUIntroduction to the Block Bindings API – Full Site Editing
- GITutorial on WP 6.5's Block Bindings API and connecting custom fields · WordPress developer-blog-content · Discussion #219 · GitHub
- WPregister_block_bindings_source() – Registers a new data source for blocks, allowing dynamic substitution of values in block attributes during their rendering.
- DEBindings – Block Editor Handbook | Developer.WordPress.org
- DEregister_block_bindings_source() – Function | Developer.WordPress.org
- DEGetting and setting Block Binding values in the Editor – WordPress Developer Blog
- MABlock Bindings: Improvements to the Editor Experience in 6.7 – Make WordPress Core
- RUWordPress Block Bindings API Explained
- BRBuilding a Woo Product Category Image Block with WordPress 6.9 – Brian Coords
- MAEditing custom fields from connected blocks – Make WordPress Core
- GUBlock Bindings API | 10up – WP Block Editor Best Practices
- MABlock Bindings improvements in WordPress 6.9 – Make WordPress Core
- MAWordPress 6.9 Field Guide – Make WordPress Core
- MAWordPress 7.0 Field Guide – Make WordPress Core
- MAPattern Overrides in WP 7.0: Support for Custom Blocks – Make WordPress Core
- MARoster of design tools per block (WordPress 7.0 edition) – Make WordPress Core
- MAMiscellaneous Developer-focused Changes in 6.9 – Make WordPress Core




