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.

Key Takeaways
  • 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-metacore/post-datacore/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).

Built-in Block Bindings sources shipped in WordPress core, by release Line chart showing the count of official server-side Block Bindings sources per WordPress release: 6.5 has 2 (core/post-meta, core/pattern-overrides), 6.6 has 2, 6.7 has 2, 6.8 has 2, and 6.9 has 4 after core/post-data and core/term-data shipped. Source: WordPress Block Editor Handbook, Bindings reference. Built-In Sources Shipped in WordPress Core Official server-side Block Bindings sources, by release 0 1 2 3 4 2 2 2 2 4 6.9 adds core/post-data and core/term-data WP 6.5 WP 6.6 WP 6.7 WP 6.8 WP 6.9 Source: WordPress Block Editor Handbook — Bindings (developer.wordpress.org)
Source: WordPress Block Editor Handbook — Bindings.

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 #64756Pattern 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 (getValuessetValuescanUserEditValue, 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.

Dual-registration architecture for a custom Block Bindings source Diagram showing that a single named source, such as acme/site-option, is registered twice under a shared identifier: once in PHP via register_block_bindings_source with a get_value_callback that controls front-end rendering and the block_bindings_source_value filter, and once in JavaScript via registerBlockBindingsSource with getValues, setValues, canUserEditValue, and getFieldsList that control editor preview, editing permission, and UI discovery. Both registrations read the same metadata.bindings entry in the block markup and must share the same source name. One Named Source, Two Independent Registrations acme/site-option — registered on both the server and the client metadata.bindings source: “acme/site-option” Server (PHP) register_block_bindings_source() • get_value_callback resolves the front-end value • uses_context injects postId, postType, etc. • block_bindings_source_value filter can override output Controls: what renders on the published page Client (JavaScript) registerBlockBindingsSource() • getValues() computes the editor preview • setValues() writes canvas edits back • canUserEditValue() / getFieldsList() gate edits, populate selector (6.9+) Controls: what editors see and can change Both sides must share the same source name — a PHP-only source is valid but has no editor preview
Both halves must use the identical source name; a mismatched or missing client registration leaves the source render-only.
SideRegistered viaResponsibilities
Server (PHP)register_block_bindings_source(), called on initResolves 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/blocksgetValues() 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.

PHP
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 referenceSanitizing 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+:

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

Cached external-API binding callback flow Flow diagram: a bound block renders and triggers the get_value_callback, which checks a transient cache. On a cache hit, the cached value is returned immediately. On a cache miss, the callback acquires a short lock transient, calls wp_remote_get with a low timeout, validates the response code and body, sanitizes and stores the result in a transient, releases the lock, and returns the value or a null fallback if the request failed. External-API Binding: Cache-First Callback Flow Block renders get_value_callback() fires get_transient() cached value? hit Return cached value immediately miss Acquire lock, wp_remote_get() timeout: 3s one worker refreshes Valid 2xx response? check is_wp_error() + status code yes Sanitize, set_transient(), return value no Log error, release lock, return null (block falls back to static markup)
The cache lookup — not the HTTP call — sits on the hot path for nearly every render. The code below implements the cache-and-fetch path; the lock step is covered as a hardening step right after it.

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.

PHP
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 HandbookEscaping 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() returning false doesn’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 atomic wp_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 of wp_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:

JavaScript
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