Skip to content
System nominalUTC --:--:--Articles 080
MODULE 04 Lesson 11 / 18 30 MIN

The public side

Registering Blocks in PHP

A block registered from block.json with a PHP render callback, with no build step, and the wrapper attributes and asset handles that make it behave.

Most tutorials for a custom block start with npm create, a few hundred megabytes of build tooling, and a JavaScript file that has to be compiled before the block exists. That is the right answer for a block with a rich editing interface. It is the wrong answer for a block whose output is decided on the server, which is a category that covers a surprising amount: a related-posts list, a price table pulled from your own table, a login-aware call to action, anything that renders the same way every time for the person looking at it.

WordPress has supported that case since 5.7, and the PHP-only path is still the one people reach for least. The block is described in block.json, registered with one call to register_block_type(), and rendered by a PHP callback that returns a string. There is no JavaScript to write, no build step, and nothing to compile. The trade-off is honest: the editor shows a placeholder, because the editor is JavaScript and your block renders in PHP.

register_block_type takes a directory and finds block.json itself

Since WordPress 6.0, register_block_type() checks whether its first argument is a path that exists on disk. If it is, the call is forwarded to register_block_type_from_metadata(), which appends block.json to a directory if the path does not already end in that filename. That means the whole registration is this:

add_action( 'init', 'wpsifero_register_blocks' );

function wpsifero_register_blocks(): void {
	register_block_type( __DIR__ . '/../blocks/badge' );
}

The return value is the registered WP_Block_Type object, or false. I registered a directory containing a valid block.json and got a WP_Block_Type whose properties had already been populated from the manifest: the render callback was set, style_handles contained wpsifero-badge-style, and editor_script_handles contained wpsifero-badge-editor-script. I also registered a path with no block.json in it and got false — plus a _doing_it_wrong() notice about namespace prefixes, because a string that is not a path is treated as a block name and handed to the registry, which rejects it. Neither half of that error message points at the directory you mistyped. Check the return value while you are developing, or the block will simply be absent.

Call it on init. Registration on init means the block is in the registry before the editor requests it and before the front end renders any content, and it leaves init free for other plugins to unregister or modify it.

block.json is the whole contract, including the PHP

The manifest names the block, declares its attributes and supports, and points at the files. The keys that matter for a PHP-rendered block are small in number.

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "wpsifero/badge",
	"title": "WPSifero Badge",
	"category": "widgets",
	"description": "A small status badge rendered by PHP.",
	"textdomain": "wpsifero",
	"supports": {
		"html": false,
		"anchor": true,
		"align": [ "left", "center", "right" ],
		"color": { "background": true, "text": true }
	},
	"attributes": {
		"label": { "type": "string", "default": "New" },
		"tone": { "type": "string", "default": "info" }
	},
	"style": "file:./style.css",
	"editorScript": "file:./index.js",
	"render": "file:./render.php"
}

render is the PHP-only path in one line. WordPress resolves the path relative to block.json and installs its own render_callback, which buffers a require of that file and returns what it printed. Inside render.php you have three variables in scope: $attributes, $content and $block, matching the three arguments a callback receives. The buffering is core's, not yours, and I confirmed that rendering a block this way leaks nothing to the outer output buffer. If you prefer to keep the callback in a class or a named function, leave render out of the manifest and pass render_callback in the second argument to register_block_type(); I verified that arguments supplied that way override the metadata, including title and render_callback.

Fields declared with the file: prefix are the ones WordPress resolves for you. file:./style.css becomes a registered stylesheet handle derived from the block name, and file:./index.js becomes a registered script handle. Both are strings you can pass to wp_enqueue_style() or dequeue later; you do not need to call wp_register_style() yourself.

Two things about the manifest are easy to get wrong. The attributes in block.json are for the editor's benefit and for server-side validation of saved markup; your render callback still has to defend itself, because a block can be rendered with attributes that never passed through the editor. And a block whose output comes from PHP needs a save function in JavaScript that returns null. If it returns markup, that markup is saved into the post and the editor's validation will disagree with your server output the first time the two drift.

get_block_wrapper_attributes is how the supports reach the front end

Declaring supports does nothing on its own. The editor saves align, anchor, className, backgroundColor and a style object as normal attributes, and the front end turns them into attributes on the element you output — but only if you ask. That is what get_block_wrapper_attributes( $extra_attributes = array() ) does, and it must wrap your block's outermost element:

<?php
/**
 * @var array    $attributes
 * @var string   $content
 * @var WP_Block $block
 */
$label = $attributes['label'] ?? 'New';
$tone  = in_array( $attributes['tone'] ?? 'info', array( 'info', 'warn' ), true )
	? $attributes['tone']
	: 'info';
?>
<span <?php echo get_block_wrapper_attributes( array( 'class' => "wpsifero-badge wpsifero-badge--$tone" ) ); ?>>
	<?php echo esc_html( $label ); ?>
</span>

The class you pass through $extra_attributes is merged with the generated one, which is why the badge keeps its own styling and still gets wp-block-wpsifero-badge for free. WordPress also merges style, id and aria-label, and passes any other key through unchanged. I rendered the same block five ways and captured the wrapper each time:

no extras                    class="wpsifero-badge wpsifero-badge--info wp-block-wpsifero-badge"
anchor                       ... wp-block-wpsifero-badge" id="badge-1"
align + className            class="wpsifero-badge wpsifero-badge--info aligncenter mine wp-block-wpsifero-badge"
backgroundColor + textColor  ... has-text-color has-white-color has-background has-vivid-red-background-color
style object                 style="color:#ff0000" class="... has-text-color"

Anchors, alignment classes and colour presets all arrive on the element because core put them there, and none of them would exist if the callback had printed a bare <span class="wpsifero-badge">. The function reads the block being rendered from WP_Block_Supports::$block_to_render, which WP_Block::render() sets immediately before it calls your callback and restores afterwards. Call it anywhere else and there is no block to read, so the result is the $extra_attributes you passed and nothing more.

Escaping is the other half of that snippet. Every attribute from $attributes is a string somebody else can control; esc_html() on the label, esc_attr() inside any attribute you build by hand, and wp_kses_post() only where the label is deliberately allowed markup.

The callback receives rendered inner blocks, and $block gives you the objects

A block that accepts inner blocks gets its InnerBlocks in the second argument, and there is a real difference between the two ways it can arrive. Rendered from a serialized post, $content holds the inner blocks already rendered to a string and $block->inner_blocks is empty. Rendered from a parsed block array, the reverse: $content is the empty string and $block->inner_blocks is a WP_Block_List. I saw both in the same request:

render_block( $parsed_array )   CONTENT=[] INNER=1 core/paragraph
do_blocks( $serialized )        CONTENT=[<p>Saved</p>] INNER=0

For most blocks $content is what you want, because it is already rendered and correctly nested. Reach for $block->inner_blocks when you need to inspect the children rather than print them — to count them, to read their attributes, or to render them in a different order. Iterate it; it implements Iterator, ArrayAccess and Countable, so it is not an array and array_map() over it is a fatal error. If you need the rendered HTML of a child on its own, render_block( $child->parsed_block ) is the function to call, and it is what do_blocks() does internally.

A dynamic block's saved markup is ignored, which is the point

Because WP_Block::render() sees that the registered type is_dynamic(), it discards the inner HTML the editor saved and uses your callback's return value instead. That is not a detail, it is the guarantee that makes server rendering worth doing: the post content is a short instruction — block name plus attributes — and the output is computed on every request. Change the callback and every existing post changes with it.

I rendered a block with deliberately stale saved markup to make sure:

do_blocks( '<!-- wp:wpsifero/badge {"label":"Sale"} --><span class="stale">OLD</span><!-- /wp:wpsifero/badge -->' );

The output was the badge for Sale, with no sign of OLD. The flip side is that nothing about the saved markup tells you the block is dynamic, so a reader of the post content cannot tell what a visitor will see. Register the block with a render file and the front end has one source of truth; register it with a save function that returns markup and you have two, which is what block validation deprecations exist to manage.

Assets are declared at registration and enqueued at render

The style and script handles from block.json are registered when the block is registered, and they are enqueued when the block is rendered. I checked the state of the queue at both points: after init, wpsifero-badge-style was registered and not enqueued; after one render_block() call it was enqueued, and WordPress printed a <link> for it with the core version as the cache-buster because the manifest declares no version. The editor script stayed registered and unenqueued on the front end, which is correct: it belongs to the editor.

That behaviour is worth understanding before you add asset code of your own. Assets declared in the manifest are conditional for free — a page with no badge loads no badge CSS — where a wp_enqueue_style() on wp_enqueue_scripts loads on every page. For a script that has to run in the browser to make the block work, use viewScript rather than editorScript; core registers it with a defer strategy and enqueues it only when the block is present. And if the output needs a stylesheet that is shared with the rest of the site, enqueue it from the render callback with wp_enqueue_style() and let WordPress merge it into the footer, rather than printing a <link> tag inside your markup.

Lab 40 minutes

Build a wpsifero/latest-orders block with no build step. It takes a count attribute defaulting to 3, supports anchor and align, reads the orders table from module 2, and renders a ul of customers and totals. Declare the styles in block.json, not in the callback.

Then answer four questions. What does register_block_type() return if the directory path is wrong, and where does the misleading notice come from? What changes on the front end if you drop get_block_wrapper_attributes() and write <ul class="wpsifero-orders"> by hand? What does $content contain for this block, and why? And when a page shows three of these blocks and another page shows none, where is the stylesheet loaded from in each case?

Show the solution and the reasoning

The registration is one call, and the callback has to escape and validate without trusting the editor. count is an integer in the manifest, but the manifest does not constrain it at render time, so clamp it where it is used.

add_action( 'init', 'wpsifero_register_order_blocks' );

function wpsifero_register_order_blocks(): void {
	register_block_type( __DIR__ . '/../blocks/latest-orders' );
}
<?php
/** @var array $attributes */
global $wpdb;

$count = isset( $attributes['count'] ) ? absint( $attributes['count'] ) : 3;
$count = max( 1, min( 10, $count ) );

$rows = $wpdb->get_results(
	$wpdb->prepare(
		"SELECT customer, total FROM {$wpdb->prefix}wpsifero_orders ORDER BY id DESC LIMIT %d",
		$count
	),
	ARRAY_A
);
?>
<ul <?php echo get_block_wrapper_attributes( array( 'class' => 'wpsifero-orders' ) ); ?>>
	<?php foreach ( $rows as $row ) : ?>
		<li><?php echo esc_html( $row['customer'] ); ?> &mdash;
			<?php echo esc_html( number_format_i18n( (float) $row['total'], 2 ) ); ?></li>
	<?php endforeach; ?>
</ul>

A wrong path returns false. The notice you get alongside it complains that block type names must contain a namespace prefix, because register_block_type() only takes the metadata route when the path exists; otherwise it treats the string as a block name and forwards it to the registry. The message points at the block name convention, not at your directory, which is why the return value is the thing to check.

Dropping get_block_wrapper_attributes() loses every support the author can set in the editor. No id for the anchor, no alignwide or alignfull class, no colour preset classes or inline colour style, no wp-block-wpsifero-latest-orders class for theme stylesheets to target. The block still renders, which is what makes this bug survivable for months.

$content is the empty string here, because this block declares no InnerBlocks and there is nothing between the block comments. It would hold the rendered children if the block used them, and $block->inner_blocks would hold the objects only for a parsed block array, which is the front-end path.

The stylesheet comes from the block's own render. Registering an asset through block.json does not enqueue it globally; core enqueues the block's style handle while rendering that block. The page with three blocks enqueues it once, because the queue is a set keyed by handle. The page with none never enqueues it and never prints the <link>. That is the difference from a wp_enqueue_scripts callback with no condition, and it is the reason to declare front-end assets in the manifest even when the block has no JavaScript.

Everything reported here was executed against WordPress 7.0.7: the registration results, the wrapper attribute output, the two $content shapes, the stale markup being discarded, and the enqueue state before and after a render. The file: resolution and the argument precedence were read in wp-includes/blocks.php.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.