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.
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'] ); ?> —
<?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.