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 edit and save.

  • Translations are automatic: title, description, keywords, style labels and variation titles use the textdomain, and the editor script gets wp_set_script_translations() when it depends on wp-i18n.

  • Tools understand it: @wordpress/scripts builds 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

$schema

URL of the JSON Schema for editor autocompletion and validation; ignored at runtime. https://schemas.wp.org/trunk/block.json follows Gutenberg trunk, https://schemas.wp.org/wp/7.1/block.json matches the release.

wp/7.1

apiVersion

Block API version; see apiVersion 3 and the iframed editor. Defaults to 1 when missing. The 7.1 schema accepts only 3.

3

name

namespace/block-name: lowercase letters, digits and hyphens, starting with a letter, exactly one slash. Saved in content (<!-- wp:lumen/book-card -->), so it can never change.

lumen/book-card

version

The block’s own version. Used as the asset version for styles, and for scripts that have no .asset.php file.

0.1.0

title

Inserter and toolbar label. Translated.

Book Card

category

Inserter group: text, media, design, widgets, theme, embed, or one added with the block_categories_all filter. With an unknown slug the editor logs a console warning and drops the category.

lumen-catalog

icon

A Dashicon slug (without dashicons-). An SVG icon can only be given in JavaScript (icon in registerBlockType()).

book-alt

description

Text shown in the block inspector. Translated.

short sentence

keywords

Extra inserter search terms. Translated.

book, isbn

textdomain

Text domain for the translated fields and for the editor script’s translations (5.7).

lumen-catalog

parent, ancestor, allowedBlocks

Where the block may be inserted (direct child of, or anywhere inside, the listed blocks; ancestor 6.0) and which blocks it accepts as children (6.5).

not used; the card sets allowedBlocks in edit

example

Attributes (and innerBlocks) for the inserter preview and the Style Book. Without it, no preview is shown.

Night Ferry

styles, variations

Block styles (name, label, isDefault) and variations, inline or as a PHP file that returns them (file form 6.7).

see Variations, Styles, Transforms and Deprecations

selectors

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

editorScript

In the editor only. Holds registerBlockType().

lumen-book-card-editor-script

both blocks

script

In the editor and on the front end, wherever the block appears. Rarely needed.

...-script

not used

viewScript

On the front end only, when the block is rendered; registered with the defer strategy (5.9).

...-view-script

not used

viewScriptModule

On the front end only, as an ES module through the Script Modules API (6.5); the field the Interactivity API uses.

...-view-script-module (a module ID)

lumen/latest-books

editorStyle

In the editor only.

lumen-book-card-editor-style

lumen/book-card

style

In the editor and on the front end.

lumen-book-card-style

both blocks

viewStyle

On the front end only (6.5).

...-view-style

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 edit output in its own element and adds the class name and alignment on save. Default when apiVersion is missing.

2

5.6

The block renders its own wrapper with useBlockProps() in edit and useBlockProps.save() in save, so the editor and front-end markup match.

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 own register_block_type() call.

  • wp_register_block_types_from_metadata_collection( $path, $manifest ) (6.8) registers the collection, when $manifest is 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.

sequenceDiagram participant P as lumen-catalog (init) participant M as blocks-manifest.php participant R as WP_Block_Type_Registry participant A as Script and style registries participant E as Block editor page participant J as index.js (editorScript) P->>M: wp_register_block_types_from_metadata_collection(build/blocks, manifest) M-->>P: metadata of book-card and latest-books P->>A: register handles from file: fields and .asset.php P->>R: register WP_Block_Type (attributes, supports, render_callback) P->>R: register_block_type('lumen/call-to-action', autoRegister) E->>R: get_block_editor_server_block_settings() R-->>E: definitions bootstrapped into the page E->>J: enqueue editorScript and editorStyle J->>E: registerBlockType(name, edit, save) merged with bootstrapped metadata E->>E: autoRegister blocks get a ServerSideRender edit and generated controls

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_args filter at priority 5 marks each attribute that can get a control with autoGenerateControl. Here all five did: only string, number, integer and boolean attributes qualify, and attributes with a source, with role local, or added by supports are skipped; label names the control.

  • On enqueue_block_editor_assets, core prints window.__unstableAutoRegisterBlocks = ["lumen\/call-to-action"]; before wp-block-library, and the editor registers each listed block with a ServerSideRender edit component. Only blocks that have both the support and a render_callback are 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 editorScript ran (build missing, wrong file: path), or the block was registered only in PHP without autoRegister on 7.0+. Check build/ and the browser console.

Block type "lumen/book-card" is already registered.

Two registrations of the same name, typically both a register_block_type() per block and the collection function. Keep one.

Block type names must not contain uppercase characters. / must contain a namespace prefix

The name in block.json breaks the naming rule. Fix it before any content uses it; renaming later orphans saved blocks.

No registered block metadata collection was found for the provided path.

wp_register_block_types_from_metadata_collection() called without $manifest for a path that was never registered as a collection.

Edits to block.json have no effect

The server reads blocks-manifest.php, which is stale. Rebuild; wp-scripts start --blocks-manifest regenerates it on every change.

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 editorStyle or enqueue_block_assets.

Block with API version 2 or lower is deprecated in the console

apiVersion is missing or below 3. Set it to 3 after testing in the iframe.

The block "lumen/book-card" is registered with an invalid category in the console

category names a slug nobody registered, so the editor drops it. Add it with block_categories_all.

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 wp_register_script()

Describe it in block.json and register the folder (or the manifest) in PHP; the server registers the assets.

register_block_type( 'name', array( 'editor_script' => 'handle' ) )

The *_handles arrays (6.1), or file: fields in block.json. The singular properties are deprecated.

ES5 with wp.element.createElement and global wp.* objects

ES modules with import from @wordpress/*, built by @wordpress/scripts, which turns the imports into script dependencies.

apiVersion omitted or 2

3; the post editor is always iframed since 7.1.

One register_block_type() per block

wp_register_block_types_from_metadata_collection() with blocks-manifest.php (6.8; collections 6.7).

Every block needs JavaScript and a build

Simple server-rendered blocks can be PHP-only with autoRegister (7.0).

The full list for the section is in Where Older Books and Tutorials Are Outdated.