block.json and Registration
|
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 is registered twice: once on the server, where WordPress records its name, attributes, assets and render
callback in WP_Block_Type_Registry, and once in the browser, where the block editor attaches the edit and save
functions. block.json is the single file both sides read, so the two registrations cannot drift apart. This page is for
plugin and block developers: it writes the block.json files of the Lumen Press blocks lumen/book-card (static) and
lumen/latest-books (dynamic), registers them from the lumen-catalog plugin with a metadata collection, adds the
PHP-only block lumen/call-to-action, and shows what WordPress made of each one.
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. Build output, registry dumps and HTML below come from a real wp-scripts build and
wp eval on a fresh 7.1.3 site, and both block.json files validate against the published 7.1 schema. The toolchain is
set up in Block Development Environment; edit, save and
render.php are covered in Static and Dynamic Blocks.
What block.json is for
The first block-editor releases described a block only in JavaScript, with its scripts and styles registered by hand in
PHP, so the server, the REST API and the Block Directory knew little about it. block.json moves the description into
data:
-
The server registers everything from it: name, attributes, supports, context, scripts, styles, script modules and the render template. Scripts and styles are enqueued only on pages that contain the block.
-
The editor reads the same metadata, bootstrapped by the server; the JavaScript only adds
editandsave. -
Translations are automatic:
title,description,keywords, style labels and variation titles use thetextdomain, and the editor script getswp_set_script_translations()when it depends onwp-i18n. -
Tools understand it:
@wordpress/scriptsbuilds the files it names, the Plugin Directory lists a plugin’s blocks, and editors validate it against the JSON Schema.
The Lumen blocks in the plugin
The blocks live in lumen-catalog/src/blocks/<name>/ and are built into lumen-catalog/build/blocks/<name>/. Each
source folder holds a block.json and the files it references:
lumen-catalog/
├── lumen-catalog.php
├── package.json # @wordpress/scripts 37.0.0 (below)
├── src/
│ ├── Plugin.php
│ ├── Blocks.php # registers the blocks (below)
│ └── blocks/
│ ├── book-card/ # block.json, index.js, edit.js, save.js, editor.scss, style.scss
│ └── latest-books/ # block.json, index.js, render.php, view.js, style.scss
└── build/blocks/ # generated by wp-scripts build; never edited
src/Blocks.php is a PHP class in the Lumen\Catalog namespace, autoloaded by Composer like Plugin.php; the
JavaScript and SCSS under src/blocks/ are inputs to the build and are not shipped.
lumen/book-card
A static block: the editor saves its HTML into the post content, and WordPress prints that HTML as it is.
{
"$schema": "https://schemas.wp.org/wp/7.1/block.json",
"apiVersion": 3,
"name": "lumen/book-card",
"version": "0.1.0",
"title": "Book Card",
"category": "lumen-catalog",
"icon": "book-alt",
"description": "A book's cover, title and ISBN, with room for a short blurb.",
"keywords": [ "book", "isbn", "catalogue" ],
"textdomain": "lumen-catalog",
"attributes": {
"bookId": { "type": "integer" },
"title": {
"type": "rich-text",
"source": "rich-text",
"selector": ".wp-block-lumen-book-card__title",
"role": "content"
},
"isbn": { "type": "string", "default": "" },
"coverUrl": { "type": "string", "source": "attribute", "selector": "img", "attribute": "src" },
"showIsbn": { "type": "boolean", "default": true }
},
"providesContext": { "lumen/bookId": "bookId" },
"supports": {
"html": false,
"align": [ "wide" ],
"anchor": true,
"color": { "background": true, "text": true, "link": true },
"spacing": { "padding": true, "margin": [ "top", "bottom" ] },
"typography": { "fontSize": true }
},
"example": {
"attributes": { "title": "Night Ferry", "isbn": "978-1-23456-793-4", "showIsbn": true }
},
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
"style": "file:./style-index.css"
}
title and coverUrl are sourced attributes: their values are read back from the saved HTML (the element matching
the selector), so they are not repeated in the block comment. bookId, isbn and showIsbn have no source and are
stored as JSON in the comment delimiter. The card provides lumen/bookId as context to the blocks inside it (its
InnerBlocks, for example the hooked "Buy at your local bookshop" block).
lumen/latest-books
A dynamic block: the editor saves only the comment delimiter with the attributes, and render.php produces the HTML on
every request, so a newly published book appears without re-saving any page.
{
"$schema": "https://schemas.wp.org/wp/7.1/block.json",
"apiVersion": 3,
"name": "lumen/latest-books",
"version": "0.1.0",
"title": "Latest Books",
"category": "lumen-catalog",
"icon": "list-view",
"description": "The most recently published books, optionally limited to one genre.",
"keywords": [ "books", "new releases" ],
"textdomain": "lumen-catalog",
"attributes": {
"count": { "type": "integer", "default": 3 },
"genre": { "type": "string", "default": "" },
"showDate": { "type": "boolean", "default": true }
},
"usesContext": [ "postId", "postType" ],
"supports": {
"html": false,
"align": [ "wide", "full" ],
"color": { "background": true, "text": true },
"spacing": { "padding": true },
"typography": { "fontSize": true, "lineHeight": true }
},
"editorScript": "file:./index.js",
"style": "file:./style-index.css",
"viewScriptModule": "file:./view.js",
"render": "file:./render.php"
}
The block reads postId and postType from its context so that, placed in a single-book template, it leaves the
current book out of the list. The context part of its render.php (the rest of the file runs a WP_Query and prints
the list with every value escaped; render.php itself is covered in
Static and Dynamic Blocks):
$lumen_catalog_args = array(
'post_type' => 'lp_book',
'posts_per_page' => max( 1, min( 12, (int) $attributes['count'] ) ),
'no_found_rows' => true,
'ignore_sticky_posts' => true,
);
// On a single book, leave the current book out of the list.
if ( 'lp_book' === ( $block->context['postType'] ?? '' ) && ! empty( $block->context['postId'] ) ) {
$lumen_catalog_args['post__not_in'] = array( (int) $block->context['postId'] );
}
render.php runs inside a closure, so its variables are local to it, but WPCS and Plugin Check still treat them as
globals: the lumen_catalog_ prefix keeps the global-prefix rule quiet, while a shorter $lumen_* name is
reported, as described in
Coding Standards and Static Analysis. view.js is a short ES module that uses Intl.RelativeTimeFormat to rewrite the <time> elements of books
published in the last month as "3 days ago"; it imports nothing, and WordPress loads it only on pages that contain the
block.
Field reference
The tables list every top-level field of the 7.1 schema, with the version that added it to
register_block_type_from_metadata() where it is later than 5.8.
Identity and inserter fields
| Field | Meaning | Lumen value |
|---|---|---|
|
URL of the JSON Schema for editor autocompletion and validation; ignored at runtime. |
|
|
Block API version; see apiVersion 3 and the iframed editor. Defaults to 1 when missing. The 7.1 schema accepts only |
|
|
|
|
|
The block’s own version. Used as the asset version for styles, and for scripts that have no |
|
|
Inserter and toolbar label. Translated. |
|
|
Inserter group: |
|
|
A Dashicon slug (without |
|
|
Text shown in the block inspector. Translated. |
short sentence |
|
Extra inserter search terms. Translated. |
|
|
Text domain for the translated fields and for the editor script’s translations (5.7). |
|
|
Where the block may be inserted (direct child of, or anywhere inside, the listed blocks; |
not used; the card sets |
|
Attributes (and |
Night Ferry |
|
Block styles ( |
|
|
CSS selectors used by Global Styles for the block and its features (6.3). |
not used |
attributes and supports
attributes declares the data the block stores. Each attribute has a type (string, number, integer, boolean,
object, array, null, or rich-text since 6.5) and optionally a default, a source (attribute, text,
html, rich-text, query, meta is deprecated) with a selector, an enum, and a role (content marks what is
editable in content-only locked patterns; local marks temporary state that is never saved). Attributes without a
source are serialized into the comment delimiter.
supports switches on editor features that WordPress implements for the block: color, spacing, typography,
dimensions, background, layout, align, anchor, className, html, inserter, multiple, reusable, lock,
interactivity, shadow, position, renaming, and more. Each support adds its own attributes (backgroundColor,
style, fontSize, align, anchor) and, on the front end, its classes and inline styles through
get_block_wrapper_attributes() or useBlockProps.save(). Both are covered in depth in
Attributes, Supports and InnerBlocks.
Context: providesContext and usesContext
Context passes values down the block tree without attributes. A block provides a context key by mapping it to one
of its own attributes ("providesContext": { "lumen/bookId": "bookId" }), and any descendant that lists the key in
usesContext receives it, in $block->context in PHP and in the context prop of edit. Core provides postId
and postType (Post Template, single templates, the post editor), queryId and query (Query Loop), and others.
Context keys of custom blocks take the plugin’s namespace.
A block only sees the keys it asks for. Rendering lumen/latest-books through new WP_Block( $parsed, $context ) with
a context of postId 6, postType lp_book and queryId 1 left $block->context with just postId and
postType, and the list held A Map of Quiet Rooms and Salt and Signal: book 6, Night Ferry, is the newest, and was left
out because the context said the block was on its page.
Asset fields
Each asset field takes a script or style handle that is already registered, a path prefixed with file: relative to
block.json, or an array of either. For a file: script, WordPress reads the sibling .asset.php file written by the
build for the dependencies and version, registers the file under a generated handle
(<namespace>-<name>-<field>), and enqueues it only when needed.
| Field | Loaded | Generated handle | Lumen |
|---|---|---|---|
|
In the editor only. Holds |
|
both blocks |
|
In the editor and on the front end, wherever the block appears. Rarely needed. |
|
not used |
|
On the front end only, when the block is rendered; registered with the |
|
not used |
|
On the front end only, as an ES module through the Script Modules API (6.5); the field the Interactivity API uses. |
|
|
|
In the editor only. |
|
|
|
In the editor and on the front end. |
|
both blocks |
|
On the front end only (6.5). |
|
not used |
lumen/wishlist combines viewScriptModule with supports.interactivity
(Interactivity API). Since 6.1, WP_Block_Type stores these as arrays
(editor_script_handles, …); the singular properties (editor_script) work only through a deprecated magic getter.
render
render names a PHP file relative to block.json (6.1). WordPress wraps it in a closure that receives $attributes
(with defaults applied), $content (the saved inner HTML) and $block (the WP_Block instance), buffers its output and
uses that as the block’s HTML. A block with render needs no save in JavaScript, or a save that returns only
<InnerBlocks.Content />. @wordpress/scripts copies the file named by render into build/; other PHP files are
copied only with --webpack-copy-php.
blockHooks
blockHooks inserts the block automatically next to an anchor block in templates, template parts and patterns, for
example "blockHooks": { "core/post-content": "after" } in the block.json of lumen/local-bookshop. Positions
are before, after, firstChild and lastChild (6.4); a block cannot hook itself. The behaviour, the
hooked_block_types filter and how hooked blocks survive template edits are in
Block Hooks.
apiVersion 3 and the iframed editor
The Block API version tells the editor how the block expects to be wrapped and where it can run:
| Version | Since | What it means |
|---|---|---|
1 |
5.0 |
The editor wraps the block’s |
2 |
5.6 |
The block renders its own wrapper with |
3 |
6.3 |
Same as 2, and the block declares that it works inside the iframed editor canvas. |
The editor canvas runs in an <iframe> so that theme and block styles apply to the content without admin CSS leaking
in, and so that viewport-relative units and media queries match the front end. The Site Editor and the template editor
have always been iframed. The post editor got there in steps:
-
6.3: iframed only when every registered block was version 3 and no classic meta box was present.
-
6.9: a block with version 2 or lower logs a deprecation in the browser console.
-
7.0: only the blocks in the post content are checked; one older block in the post falls back to the non-iframed editor for that post.
-
7.1: the post editor is always iframed, whatever the blocks' versions, the theme or the content. The Gutenberg plugin has forced the iframed post editor since 22.6. A block registered with version 1 or 2 still loads, but now runs inside the iframe.
So setting "apiVersion": 3 no longer changes where a block runs in 7.1; it states that the block has been tested
there. What breaks inside the iframe is code that reaches for the admin page: the editor scripts run in the parent
window, but the block’s DOM lives in the iframe’s document. document.querySelector() from an edit component finds
nothing, and listeners on window never fire for the canvas. CSS enqueued on admin pages (admin_enqueue_scripts)
does not reach the content, and styles added with enqueue_block_editor_assets are copied into the iframe only as a
fallback, with a console warning ("was added to the iframe incorrectly"). Use the block’s own editorStyle and style
(or enqueue_block_assets, which loads inside the iframe), and get the document from an element:
import { useRefEffect } from '@wordpress/compose';
import { useBlockProps } from '@wordpress/block-editor';
export default function Edit() {
const ref = useRefEffect( ( element ) => {
const { defaultView } = element.ownerDocument; // the iframe's window, not the admin page's.
const onResize = () => element.classList.toggle( 'is-narrow', defaultView.innerWidth < 600 );
onResize();
defaultView.addEventListener( 'resize', onResize );
return () => defaultView.removeEventListener( 'resize', onResize );
}, [] );
return <div { ...useBlockProps( { ref } ) }>…</div>;
}
The full list of cases to watch for is the "Technical considerations for the iframe editor" section of the handbook
page above, which the Make WordPress Core post on the 7.1 change points to; the usual fix is the same: resolve
ownerDocument and defaultView from a ref.
Building the blocks
@wordpress/scripts finds every block.json under the source path, builds each script, module and style it names,
copies block.json and the render file, and with --blocks-manifest writes one PHP file with the metadata of all
blocks. viewScriptModule files are only built with --experimental-modules:
{
"name": "lumen-catalog",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "wp-scripts build --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules",
"start": "wp-scripts start --source-path=src/blocks --output-path=build/blocks --blocks-manifest --experimental-modules",
"lint:js": "wp-scripts lint-js src/blocks"
},
"devDependencies": {
"@wordpress/scripts": "37.0.0"
}
}
--source-path replaces the older --webpack-src-dir, which 37.0.0 still accepts. npm run build writes, per block,
block.json, index.js with index.asset.php, the CSS (editor.scss imported by index.js becomes index.css,
style.scss becomes style-index.css, each with an -rtl twin for right-to-left languages), render.php,
view.js with view.asset.php, and build/blocks/blocks-manifest.php for all blocks. book-card/index.asset.php returns
the WordPress packages the script imports, which are loaded from core rather than bundled (react-jsx-runtime,
wp-block-editor, wp-blocks, wp-components, wp-i18n), and a content hash as the version (29cc23afde8cfc4ca016).
view.asset.php has 'type' => 'module' and no dependencies. blocks-manifest.php returns an array keyed by folder
name, with each block.json converted to PHP (abridged):
<?php
// This file is generated. Do not modify it manually.
return array(
'book-card' => array(
'$schema' => 'https://schemas.wp.org/wp/7.1/block.json',
'apiVersion' => 3,
'name' => 'lumen/book-card',
// ... every other block.json field, as PHP arrays
),
'latest-books' => array( /* ... */ ),
);
The manifest is a build artefact: it is regenerated on every build and shipped with build/, and it must be rebuilt
whenever a block.json changes, or the server keeps registering the old metadata.
Registering on the server
register_block_type()
register_block_type() accepts a path to a folder containing block.json (or to the file), and then calls
register_block_type_from_metadata(), which reads it, translates the fields, registers the assets and the render
callback and adds the block to the registry, with an optional second argument overriding the file’s values. It also
accepts a block name with an array of WP_Block_Type properties in snake_case (api_version, attributes,
render_callback, editor_script_handles), the pre-5.8 style still used for PHP-only blocks, or a WP_Block_Type
object. It must run on init and returns the WP_Block_Type or false, reporting failures with
_doing_it_wrong() notices.
Metadata collections and blocks-manifest.php
Registering one block per call reads and decodes one JSON file per block on every request. WordPress 6.7 added
block metadata collections: a plugin registers its build folder and the generated manifest once, and
register_block_type_from_metadata() then takes the metadata from the PHP array (which OPcache keeps in memory) instead
of the JSON file. Two functions exist, and the version matters:
-
wp_register_block_metadata_collection( $path, $manifest )(6.7) registers the collection only; each block still needs its ownregister_block_type()call. -
wp_register_block_types_from_metadata_collection( $path, $manifest )(6.8) registers the collection, when$manifestis given, and then every block in it.
lumen-catalog requires 6.9, so it uses the 6.8 function. A plugin that still supports 6.7 calls the 6.7 function and
loops over the manifest keys; one that supports older versions guards both with function_exists(). The collection
path must be a folder of its own: WordPress refuses to register wp-includes, wp-content, the plugins, must-use
plugins or theme directories themselves, or one of their parents, as a collection.
The Blocks class
<?php
namespace Lumen\Catalog;
defined( 'ABSPATH' ) || exit;
// Registers the plugin's blocks and their inserter category.
final class Blocks {
// Called from Plugin::boot().
public static function boot(): void {
add_action( 'init', array( self::class, 'register' ) );
add_filter( 'block_categories_all', array( self::class, 'add_category' ) );
}
// Registers every block built into build/blocks/ from one manifest file.
public static function register(): void {
$path = dirname( LUMEN_CATALOG_FILE ) . '/build/blocks';
wp_register_block_types_from_metadata_collection( $path, $path . '/blocks-manifest.php' );
self::register_call_to_action();
}
// Adds the "Lumen Catalog" category used by the blocks' "category" field.
public static function add_category( array $categories ): array {
$categories[] = array(
'slug' => 'lumen-catalog',
'title' => __( 'Lumen Catalog', 'lumen-catalog' ),
'icon' => null,
);
return $categories;
}
// register_call_to_action() and render_call_to_action(): see "PHP-only blocks" below.
}
Plugin::boot() gains one line, Blocks::boot();, next to its other hooks. LUMEN_CATALOG_FILE is the main plugin
file constant defined in Plugin Fundamentals.
Three filters run during registration, in order: block_type_metadata (the decoded block.json array, from the file
or the manifest), block_type_metadata_settings (the settings built from it, with the generated handles) and
register_block_type_args (the final arguments of every registration path; core hooks it at priority 5 for PHP-only
blocks). They also see core blocks, so a site plugin can, for example, remove a support from core/heading.
How the editor registers a block
The client side does not read block.json from disk. When the editor loads, WordPress serializes the server registry
(get_block_editor_server_block_settings()) into the page, and editorScript calls registerBlockType() with the
metadata and the JavaScript-only settings:
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
import save from './save';
import metadata from './block.json';
import './style.scss';
import './editor.scss';
registerBlockType( metadata.name, {
edit: Edit,
save,
} );
registerBlockType() merges its defaults, the server-bootstrapped definition and the object passed in, then runs the
blocks.registerBlockType filter; metadata.name is enough because the server sent the rest. A block registered only
in JavaScript has no server definition: no assets are enqueued for it, render_block() does not know its attribute
defaults, and the REST block-types endpoint does not list it.
Checking the registry with WP-CLI
WP_Block_Type_Registry::get_instance() holds every block registered on the request. A small script run with
wp eval-file shows what the server made of each block.json:
<?php
foreach ( WP_Block_Type_Registry::get_instance()->get_all_registered() as $name => $type ) {
if ( ! str_starts_with( $name, 'lumen/' ) ) {
continue;
}
echo $name, ' render_callback=', is_callable( $type->render_callback ) ? 'yes' : 'no', "\n";
foreach ( array( 'api_version', 'editor_script_handles', 'editor_style_handles', 'style_handles', 'view_script_module_ids', 'uses_context', 'provides_context' ) as $prop ) {
if ( ! empty( $type->$prop ) ) {
echo ' ', $prop, '=', wp_json_encode( $type->$prop, JSON_UNESCAPED_SLASHES ), "\n";
}
}
echo ' attributes=', implode( ',', array_keys( $type->attributes ) ), "\n";
}
lumen/book-card render_callback=no
api_version=3
editor_script_handles=["lumen-book-card-editor-script"]
editor_style_handles=["lumen-book-card-editor-style"]
style_handles=["lumen-book-card-style"]
provides_context={"lumen/bookId":"bookId"}
attributes=bookId,title,isbn,coverUrl,showIsbn,lock,metadata,align,className,style,backgroundColor,textColor,fontSize,anchor
lumen/latest-books render_callback=yes
api_version=3
editor_script_handles=["lumen-latest-books-editor-script"]
style_handles=["lumen-latest-books-style"]
view_script_module_ids=["lumen-latest-books-view-script-module"]
uses_context=["postId","postType"]
attributes=count,genre,showDate,lock,metadata,align,className,style,backgroundColor,textColor,fontSize
lumen/call-to-action render_callback=yes
api_version=3
attributes=heading,linkText,url,tone,newTab,lock,metadata,align,className,style
The attributes after the block’s own are added by WordPress: lock and metadata for every block, className unless
supports.className is false, and one or more per support (align, style, backgroundColor, textColor,
fontSize, anchor). render_callback is the closure built from render. The script
lumen-book-card-editor-script points at build/blocks/book-card/index.js with the five dependencies and the hash
version from index.asset.php; the style lumen-book-card-style uses the block’s version, 0.1.0 (or the file’s
modification time when SCRIPT_DEBUG is on). WP_Block_Metadata_Registry::has_metadata( $path . '/book-card' ) returns true, which confirms that the metadata
came from the manifest rather than from block.json.
Rendered output
A page containing the three blocks is stored as:
<!-- wp:lumen/book-card {"bookId":6,"isbn":"978-1-23456-793-4"} -->
<article class="wp-block-lumen-book-card"><h3 class="wp-block-lumen-book-card__title">Night Ferry</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-793-4</p><!-- wp:paragraph -->
<p>A crossing, a storm, a missing passenger.</p>
<!-- /wp:paragraph --></article>
<!-- /wp:lumen/book-card -->
<!-- wp:lumen/latest-books {"count":2} /-->
<!-- wp:lumen/call-to-action {"tone":"ink"} /-->
and served at https://lumenpress.test/new-books/ as (inside Post Content):
<article class="wp-block-lumen-book-card"><h3 class="wp-block-lumen-book-card__title">Night Ferry</h3><p class="wp-block-lumen-book-card__isbn">978-1-23456-793-4</p>
<p class="wp-block-paragraph">A crossing, a storm, a missing passenger.</p>
</article>
<aside class="is-tone-ink wp-block-lumen-call-to-action"><p class="wp-block-lumen-call-to-action__heading">Have a manuscript?</p><a href="/submissions/">Read our submission guidelines</a></aside>
Between them, lumen/latest-books printed a <ul class="wp-block-lumen-latest-books"> with Night Ferry and A Map of
Quiet Rooms. The <head> of the same page carries the two style files inline (lumen-book-card-style-inline-css,
lumen-latest-books-style-inline-css) and the module
<script id="lumen-latest-books-view-script-module-js-module" src="…/build/blocks/latest-books/view.js?ver=5a9580424a03f2b76fc4" type="module">.
A page without the blocks loads none of them.
PHP-only blocks
WordPress 7.0 added blocks that need no JavaScript and no build step. A block registered in PHP with a
render_callback and 'supports' => array( 'autoRegister' => true ) is registered in the editor automatically,
previewed with server-side rendering, and given inspector controls generated from its attributes. Lumen uses one for
its submissions call to action, the second half of the Blocks class:
// Registers lumen/call-to-action, a PHP-only block (WordPress 7.0+).
private static function register_call_to_action(): void {
register_block_type(
'lumen/call-to-action',
array(
'api_version' => 3,
'title' => __( 'Call to Action', 'lumen-catalog' ),
'category' => 'lumen-catalog',
'icon' => 'megaphone',
'description' => __( 'A sentence and a link to the submissions page.', 'lumen-catalog' ),
'attributes' => array(
'heading' => array( 'type' => 'string', 'label' => __( 'Heading', 'lumen-catalog' ), 'default' => __( 'Have a manuscript?', 'lumen-catalog' ) ),
'linkText' => array( 'type' => 'string', 'label' => __( 'Link text', 'lumen-catalog' ), 'default' => __( 'Read our submission guidelines', 'lumen-catalog' ) ),
'url' => array( 'type' => 'string', 'label' => __( 'Link URL', 'lumen-catalog' ), 'default' => '/submissions/' ),
'tone' => array( 'type' => 'string', 'label' => __( 'Tone', 'lumen-catalog' ), 'enum' => array( 'paper', 'ink' ), 'default' => 'paper' ),
'newTab' => array( 'type' => 'boolean', 'label' => __( 'Open in a new tab', 'lumen-catalog' ), 'default' => false ),
),
'supports' => array(
'autoRegister' => true,
'html' => false,
'align' => array( 'wide' ),
'spacing' => array( 'padding' => true ),
),
'render_callback' => array( self::class, 'render_call_to_action' ),
)
);
}
// Renders the block on the front end and in the editor preview; $attributes has defaults applied.
public static function render_call_to_action( array $attributes ): string {
$wrapper = get_block_wrapper_attributes(
array( 'class' => 'is-tone-' . sanitize_html_class( $attributes['tone'] ) )
);
$target = $attributes['newTab'] ? ' target="_blank" rel="noopener"' : '';
return sprintf(
'<aside %1$s><p class="wp-block-lumen-call-to-action__heading">%2$s</p><a href="%3$s"%4$s>%5$s</a></aside>',
$wrapper,
esc_html( $attributes['heading'] ),
esc_url( $attributes['url'] ),
$target,
esc_html( $attributes['linkText'] )
);
}
How it works in 7.1.3, as read from the registry on the running site:
-
A
register_block_type_argsfilter at priority 5 marks each attribute that can get a control withautoGenerateControl. Here all five did: onlystring,number,integerandbooleanattributes qualify, and attributes with asource, withrolelocal, or added by supports are skipped;labelnames the control. -
On
enqueue_block_editor_assets, core printswindow.__unstableAutoRegisterBlocks = ["lumen\/call-to-action"];beforewp-block-library, and the editor registers each listed block with aServerSideRenderedit component. Only blocks that have both the support and arender_callbackare listed.
The limits are deliberate: no InnerBlocks, no save, no rich text, media pickers or textareas, no sourced attributes,
and each attribute change costs a REST round trip for the preview (in which postId context is not populated in 7.1).
PHP-only blocks suit small, configurable,
server-rendered pieces such as the call to action; the book card stays a JavaScript block. WordPress 6.9, the plugin’s
minimum, has no autoRegister: the block renders on the front end, but nothing registers it in the editor.
Block icons and the 7.1 Icons API
A block’s icon in block.json is a Dashicon slug. For an SVG, set icon in registerBlockType() to an element or
an object with src, background and foreground; a PHP-only block, which has no JavaScript, uses a Dashicon slug.
WordPress 7.1 added an Icons API (wp_register_icon_collection(), wp_register_icon(), wp_get_icon() and the
read-only /wp/v2/icons and /wp/v2/icon-collections REST routes). It does not change block type icons: in 7.1.3
the editor still resolves a block’s icon exactly as before. The registry feeds the core Icon block’s picker, which
groups icons by collection, and lets PHP print a sanitized SVG. Lumen registers its own collection for use in content
and in render.php:
add_action(
'init',
static function () {
wp_register_icon_collection( 'lumen', array( 'label' => __( 'Lumen Press', 'lumen-catalog' ) ) );
wp_register_icon(
'lumen/open-book',
array(
'label' => __( 'Open book', 'lumen-catalog' ),
'content' => '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 5h7a2 2 0 0 1 2 2v12a2 2 0 0 0-2-2H3zM21 5h-7a2 2 0 0 0-2 2v12a2 2 0 0 1 2-2h7z"/></svg>',
)
);
}
);
// Later, in a template or render.php:
echo wp_get_icon( 'lumen/open-book', array( 'size' => 20, 'label' => __( 'Book', 'lumen-catalog' ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
wp_get_icon() returned the SVG with width and height set to 20, role="img" and aria-label="Book", and
GET /wp/v2/icons/lumen (for a user who can edit_posts) returned ["lumen/open-book"]. Icon names are
collection/icon-name, the collection must exist first, core is the collection WordPress uses for its own icons, and
the SVG is reduced to a small allowlist of elements (strokes are removed), so icons should be drawn with fills.
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
Block missing from the inserter, "Your site doesn’t include support for this block" in content |
The server registered it but no |
|
Two registrations of the same name, typically both a |
|
The name in |
|
|
Edits to |
The server reads |
Editor-only CSS not applied in the post editor |
It was enqueued for the admin page; since 7.1 the canvas is always an iframe. Use |
|
|
|
|
Security
block.json is not a trust boundary. Attributes come from post content, which any user with edit_posts can write by
hand in the code editor or through the REST API, so render.php and render callbacks treat them as untrusted input:
cast numbers, sanitize_key() slugs and escape every value on output, as the Lumen callbacks do;
get_block_wrapper_attributes() is the one value printed unescaped, because it returns escaped attributes. A dynamic block that queries the database must not reveal drafts or private posts: get_posts()
returns only published posts by default, and any change to post_status needs a capability check. Static blocks are
filtered by KSES on save for users without unfiltered_html, so markup a block’s save produces must survive KSES, or
the block becomes invalid when an Author edits it. The fuller treatment is 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 development or block.json; Pro WordPress builds features as shortcodes and widgets, and the Ultimate
WordPress Handbook still says "reusable blocks", which 6.3 renamed synced patterns. Tutorials written for WordPress 5.x
are the usual source of outdated registration code:
| Older advice | Current practice |
|---|---|
Register the block in JavaScript with all its settings, and the script and style by hand with |
Describe it in |
|
The |
ES5 with |
ES modules with |
|
|
One |
|
Every block needs JavaScript and a build |
Simple server-rendered blocks can be PHP-only with |
The full list for the section is in Where Older Books and Tutorials Are Outdated.
References
-
Block Editor Handbook: Static or Dynamic rendering of a block
-
Block Editor Handbook: @wordpress/dependency-extraction-webpack-plugin
-
Code Reference: wp_register_block_types_from_metadata_collection()
-
Make WordPress Core: New block type registration APIs to improve performance in WordPress 6.7
-
Make WordPress Core: More efficient block type registration in 6.8
-
Make WordPress Core: Iframed Editor Changes in WordPress 7.1
-
Make WordPress Core: Registering and rendering SVG icons in WordPress 7.1
-
WordPress 7.1.3: wp-includes/block-supports/auto-register.php