When Gutenberg shipped with WordPress 5.0 in November 2018, I — like many long-time WordPress developers — felt pushed to the sidelines. Overnight, creating custom blocks meant learning React, setting up a Node.js build pipeline, understanding Webpack, JSX, and a new JavaScript-first paradigm. The PHP skills I had spent years honing suddenly felt like a second-class citizen in my own CMS.
I kept building with PHP: shortcodes, widget APIs, template parts. But I watched the block editor ecosystem evolve without me at its centre. Every time someone asked “how do I build a custom block?”, the answer was a create-block scaffold, a package.json, and a build step.
That changes with WordPress 7.0.
On March 3, 2026, Miguel Fonseca published the official dev note for a feature that I genuinely believe is the most developer-friendly addition to the block editor since it launched: PHP-only block registration.
This is THE FIRST block-editor-related good news since Gutenberg was first implemented!
What Is PHP-Only Block Registration?
It is exactly what it sounds like. You call register_block_type() with a single new flag — autoRegister => true inside supports — and WordPress automatically exposes your block in the editor. No JavaScript. No build step. No npm install. Just PHP.
The editor receives a list of these PHP-registered blocks via a JavaScript global (autoRegisterBlocks in the editor settings) and uses ServerSideRender to preview them. When you change an attribute in the Inspector Controls panel, the editor calls the WordPress REST API, which runs your PHP render_callback, and displays the result. The loop is: change value → REST call → PHP render → update preview.
add_action( 'init', function () {
register_block_type(
'my-plugin/hello',
array(
'title' => 'Hello Block',
'render_callback' => function ( $attributes ) {
return '<p>' . esc_html( $attributes['message'] ) . '</p>';
},
'attributes' => array(
'message' => array(
'type' => 'string',
'default' => 'Hello, world!',
),
),
'supports' => array(
'autoRegister' => true,
),
)
);
} );
That is a fully functional Gutenberg block. No src/ directory. No block.json. No edit.js. Save this in a plugin file, activate it, and you will find “Hello Block” in the block inserter, with a text field in the sidebar automatically generated for the message attribute.
Auto-Generated Inspector Controls
WordPress inspects the attributes array and generates sidebar controls automatically:
| Attribute schema | Generated control |
|---|---|
'type' => 'string' | TextControl |
'type' => 'integer' or 'type' => 'number' | NumberControl |
'type' => 'boolean' | ToggleControl |
'type' => 'string' + 'enum' => [...] | SelectControl |
Controls are not generated for:
- Attributes with
"role": "local"(local/transient state) - Types other than the above (
object,array, etc.) - Attributes whose names are reserved by block supports (
style,className,textColor,backgroundColor,fontSize,fontFamily,align,anchor)
Custom Labels
By default the label is derived from the attribute name (camelCase gets split into words). You can override it with a label key:
'buttonUrl' => array(
'type' => 'string',
'default' => '#',
'label' => 'Button URL',
),
'openInNewTab' => array(
'type' => 'boolean',
'default' => false,
'label' => 'Open in New Tab',
),
'headingStyle' => array(
'type' => 'string',
'enum' => array( '1', '2', '3' ),
'default' => '1',
'label' => 'Heading Style',
),
Sentence-Case Enum Values
The enum values are used directly as SelectControl option labels. Use sentence case so they read properly in the UI:
'speed' => array(
'type' => 'string',
'enum' => array( 'Slow', 'Medium', 'Fast' ),
'default' => 'Medium',
'label' => 'Speed',
),
Block Supports
This is where autoRegister really shines. The supports array accepts the same keys as any JavaScript-registered block, and get_block_wrapper_attributes() in the render callback handles all the output automatically.
'supports' => array(
'autoRegister' => true,
'align' => array( 'wide', 'full' ),
'color' => array(
'text' => true,
'background' => true,
),
'typography' => array(
'fontSize' => true,
'lineHeight' => true,
'fontWeight' => true,
'fontStyle' => true,
'letterSpacing' => true,
'textTransform' => true,
'textDecoration' => true,
),
'spacing' => array(
'margin' => true,
'padding' => true,
),
'border' => array(
'color' => true,
'radius' => true,
'style' => true,
'width' => true,
),
),
Declaring these unlocks the native Color, Typography, Spacing, and Border panels in the block sidebar. No extra PHP is needed in the render callback — get_block_wrapper_attributes() applies the corresponding CSS classes and inline styles to the wrapper element.
'render_callback' => function ( $attributes ) {
$wrapper_attrs = get_block_wrapper_attributes(
array( 'class' => 'my-block' )
);
return sprintf( '<div %s>...</div>', $wrapper_attrs );
},
Requires Gutenberg 21.9+ (or WordPress 7.0+) for correct editor rendering of border attributes. An earlier double-rendering bug was fixed in Gutenberg #72039.
Critical Gotchas
1. The style Naming Collision
This one will bite you. When you enable color, spacing, or typography block supports, WordPress automatically registers an implicit attribute named style (type object) to persist values like { "color": { "background": "#abc" }, "spacing": { "padding": "1rem" } }.
If you define your own attribute named style as a string, the REST API’s server-side type checking will reject it with:
Error loading block: Invalid parameter(s): attributes
Never name a custom attribute style. Rename it — if it was a visual variant selector, call it variant. Full list of reserved implicit attribute names:
| Support | Reserved names |
|---|---|
color.background | backgroundColor, style |
color.text | textColor, style |
color.gradient | gradient, style |
spacing.* | style |
typography.fontSize | fontSize, style |
typography.fontFamily | fontFamily, style |
align | align |
anchor | anchor |
| Always | className |
2. Block Stylesheet Not Applied in the Editor
wp_enqueue_block_style() relies on the client-side block registration system. Because autoRegister blocks register via a JS global rather than registerBlockType, the stylesheet injection is not triggered in the editor.
Workaround: enqueue the stylesheet unconditionally for the editor via enqueue_block_editor_assets:
// Frontend — loads only when block is present.
wp_enqueue_block_style( 'my-plugin/my-block', array(
'handle' => 'my-block-style',
'src' => plugins_url( 'blocks/my-block.css', __FILE__ ),
'ver' => MY_PLUGIN_VERSION,
) );
// Editor — loads on every editor page for this block's CSS to work in preview.
add_action( 'enqueue_block_editor_assets', function () {
wp_enqueue_style(
'my-block-style',
plugins_url( 'blocks/my-block.css', __FILE__ ),
array(),
MY_PLUGIN_VERSION
);
} );
If you have multiple PHP-only blocks, consolidate them into a single stylesheet and use a shared handle — WordPress deduplicates automatically.
3. CSS Custom Properties for Dynamic Decoration Colors
Block supports give you a text colour picker and a background colour picker. But sometimes you need a third colour — an accent that drives ::before/::after pseudo-elements, decorative borders, or SVG fills.
CSS pseudo-elements can’t receive inline styles directly, but they can read CSS custom properties. Set the property on the wrapper element in the render callback, and reference it in your stylesheet with a currentColor fallback so it automatically follows the text colour when no override is set:
// In render_callback:
$accent = ! empty( $attributes['accentColor'] )
? '--accent:' . esc_attr( $attributes['accentColor'] ) . ';'
: '';
$wrapper_attrs = get_block_wrapper_attributes( array(
'class' => 'my-block',
'style' => $accent,
) );
/* In stylesheet: */
.my-block__decoration {
background-color: var(--accent, currentColor);
}
If accentColor is empty, the decoration inherits the text colour set via color.text. If set explicitly, it uses that value. You get two-tier control with zero JavaScript.
4. Loading Frontend JavaScript Lazily
If your block needs JavaScript (animations, interactive widgets), register it at init but enqueue it from the render callback — WordPress queues it for wp_footer and deduplicates it automatically across multiple instances of the block on the same page.
add_action( 'init', function () {
wp_register_script(
'my-block-script',
plugins_url( 'blocks/my-block.js', __FILE__ ),
array(),
MY_PLUGIN_VERSION,
true // in footer
);
} );
// In render_callback:
wp_enqueue_script( 'my-block-script' ); // no-op if already enqueued
Complete Mini-Plugin Examples
Example 1 — Alert / Notice Block
A simple block with a message, a severity level, and a dismissible toggle. No JavaScript, no build step, no dependencies.
<?php
/**
* Plugin Name: My Alert Block
* Description: A simple notice/alert block for PHP developers.
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
add_action( 'init', function () {
register_block_type(
'my-plugin/alert',
array(
'title' => 'Alert',
'description' => 'A notice or alert message.',
'category' => 'text',
'icon' => 'warning',
'attributes' => array(
'message' => array(
'type' => 'string',
'default' => 'This is an important notice.',
'label' => 'Message',
),
'severity' => array(
'type' => 'string',
'enum' => array( 'Info', 'Warning', 'Error', 'Success' ),
'default' => 'Info',
'label' => 'Severity',
),
'dismissible' => array(
'type' => 'boolean',
'default' => false,
'label' => 'Dismissible',
),
),
'supports' => array(
'autoRegister' => true,
'color' => array( 'text' => true, 'background' => true ),
'spacing' => array( 'padding' => true ),
'border' => array( 'color' => true, 'radius' => true,
'style' => true, 'width' => true ),
),
'render_callback' => function ( $attributes ) {
$severity = strtolower( $attributes['severity'] );
$wrapper = get_block_wrapper_attributes( array(
'class' => 'my-alert my-alert--' . $severity,
'role' => 'alert',
'aria-label' => $attributes['severity'],
) );
$dismiss = $attributes['dismissible']
? '<button class="my-alert__dismiss" aria-label="Dismiss">×</button>'
: '';
return sprintf(
'<div %s>%s<p class="my-alert__message">%s</p></div>',
$wrapper,
$dismiss,
esc_html( $attributes['message'] )
);
},
)
);
} );
/* alert.css */
.my-alert {
padding: 1rem 1.25rem;
border-left: 4px solid currentColor;
position: relative;
}
.my-alert--info { border-color: #3b82f6; }
.my-alert--warning { border-color: #f59e0b; }
.my-alert--error { border-color: #ef4444; }
.my-alert--success { border-color: #10b981; }
.my-alert__dismiss {
position: absolute; top: 0.5rem; right: 0.75rem;
background: none; border: none; font-size: 1.25rem; cursor: pointer;
}
Example 2 — Scrolling Marquee Block
A single scrolling text row driven by requestAnimationFrame. Typography, colour, and spacing are all controlled via native block support panels. Multiple instances on a page share a single script load.
plugin.php
<?php
/**
* Plugin Name: My Marquee Block
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
define( 'MY_MARQUEE_VERSION', '1.0.0' );
add_action( 'init', function () {
// Register script — not enqueued yet.
wp_register_script(
'my-marquee',
plugin_dir_url( __FILE__ ) . 'marquee.js',
array(),
MY_MARQUEE_VERSION,
true
);
register_block_type(
'my-plugin/marquee',
array(
'title' => 'Marquee',
'description' => 'A single scrolling text row.',
'category' => 'text',
'icon' => 'leftright',
'attributes' => array(
'text' => array(
'type' => 'string',
'default' => 'Your scrolling text here —',
'label' => 'Text',
),
'speed' => array(
'type' => 'string',
'enum' => array( 'Slow', 'Medium', 'Fast' ),
'default' => 'Medium',
'label' => 'Speed',
),
'direction' => array(
'type' => 'string',
'enum' => array( 'Left', 'Right' ),
'default' => 'Left',
'label' => 'Direction',
),
),
'supports' => array(
'autoRegister' => true,
'align' => array( 'wide', 'full' ),
'color' => array( 'text' => true, 'background' => true ),
'spacing' => array( 'padding' => true ),
'typography' => array(
'fontSize' => true,
'fontWeight' => true,
'fontStyle' => true,
),
),
'render_callback' => function ( $attributes ) {
wp_enqueue_script( 'my-marquee' ); // lazy — deduplicated
$speed_map = array( 'Slow' => 0.5, 'Medium' => 1.0, 'Fast' => 2.0 );
$step = $speed_map[ $attributes['speed'] ] ?? 1.0;
$direction = ( 'Right' === $attributes['direction'] ) ? 'right' : 'left';
$wrapper = get_block_wrapper_attributes(
array( 'class' => 'my-marquee' )
);
return sprintf(
'<div %s><div class="my-marquee__row" data-step="%s" data-repeat="16" data-direction="%s">%s</div></div>',
$wrapper,
esc_attr( $step ),
esc_attr( $direction ),
esc_html( $attributes['text'] )
);
},
)
);
} );
marquee.js
( function () {
'use strict';
function startMarquee( el, repeatCount, step, direction ) {
var content = el.innerHTML;
var position = 0;
// Measure single copy BEFORE duplicating.
el.style.position = 'absolute';
var singleWidth = el.clientWidth + 1;
el.style.position = '';
el.innerHTML = Array( repeatCount ).fill( content ).join( '' );
function animate() {
position = position < singleWidth ? position + step : 1;
el.style.marginLeft = ( direction === 'right'
? position - singleWidth
: -position ) + 'px';
requestAnimationFrame( animate );
}
animate();
}
function init() {
document.querySelectorAll( '.my-marquee__row' ).forEach( function ( el ) {
startMarquee(
el,
parseInt( el.dataset.repeat, 10 ) || 12,
parseFloat( el.dataset.step ) || 1,
el.dataset.direction || 'left'
);
} );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', init );
} else {
init();
}
}() );
marquee.css
.my-marquee { overflow: hidden; width: 100%; }
.my-marquee__row { overflow: hidden; white-space: nowrap; }
Example 3 — Author Box Block
A server-rendered author card that pulls data from WordPress user meta. Demonstrates a dynamically-built enum (populated from get_users() at init time), user meta integration, and profile field extension.
<?php
/**
* Plugin Name: My Author Box Block
* Version: 1.0.0
*/
defined( 'ABSPATH' ) || exit;
// Add LinkedIn field to user profile.
add_action( 'show_user_profile', 'my_author_box_linkedin_field' );
add_action( 'edit_user_profile', 'my_author_box_linkedin_field' );
function my_author_box_linkedin_field( WP_User $user ) {
?>
<h3><?php esc_html_e( 'Professional Links', 'my-author-box' ); ?></h3>
<table class="form-table">
<tr>
<th><label for="linkedin_url"><?php esc_html_e( 'LinkedIn URL', 'my-author-box' ); ?></label></th>
<td><input type="url" name="linkedin_url" id="linkedin_url"
value="<?php echo esc_attr( get_user_meta( $user->ID, 'linkedin_url', true ) ); ?>"
class="regular-text"></td>
</tr>
</table>
<?php
}
add_action( 'personal_options_update', 'my_author_box_save_linkedin' );
add_action( 'edit_user_profile_update', 'my_author_box_save_linkedin' );
function my_author_box_save_linkedin( int $user_id ) {
if ( current_user_can( 'edit_user', $user_id ) && isset( $_POST['linkedin_url'] ) ) {
update_user_meta( $user_id, 'linkedin_url', esc_url_raw( $_POST['linkedin_url'] ) );
}
}
// Register the block.
add_action( 'init', function () {
$users = get_users( array( 'number' => -1, 'fields' => array( 'user_login' ) ) );
$user_enum = array_values( array_map( fn( $u ) => $u->user_login, $users ) );
if ( empty( $user_enum ) ) return;
register_block_type(
'my-plugin/author-box',
array(
'title' => 'Author Box',
'category' => 'text',
'icon' => 'admin-users',
'attributes' => array(
'userLogin' => array(
'type' => 'string',
'enum' => $user_enum,
'default' => $user_enum[0],
'label' => 'User',
),
'showAvatar' => array(
'type' => 'boolean',
'default' => true,
'label' => 'Show Avatar',
),
'showBio' => array(
'type' => 'boolean',
'default' => true,
'label' => 'Show Bio',
),
'showEmail' => array(
'type' => 'boolean',
'default' => true,
'label' => 'Show Email',
),
'showLinkedin' => array(
'type' => 'boolean',
'default' => true,
'label' => 'Show LinkedIn',
),
),
'supports' => array(
'autoRegister' => true,
'align' => array( 'wide', 'full' ),
'color' => array( 'text' => true, 'background' => true ),
'spacing' => array( 'padding' => true ),
'border' => array( 'radius' => true ),
),
'render_callback' => function ( $attributes ) {
$user = get_user_by( 'login', $attributes['userLogin'] );
if ( ! $user ) return '';
$wrapper = get_block_wrapper_attributes(
array( 'class' => 'my-author-box' )
);
$avatar = $attributes['showAvatar']
? get_avatar( $user->ID, 80, '', '', array( 'class' => 'my-author-box__avatar' ) )
: '';
$bio = $attributes['showBio'] && $user->description
? '<p class="my-author-box__bio">' . esc_html( $user->description ) . '</p>'
: '';
$email = $attributes['showEmail']
? '<a href="mailto:' . esc_attr( $user->user_email ) . '">' . esc_html( $user->user_email ) . '</a>'
: '';
$linkedin = $attributes['showLinkedin']
? get_user_meta( $user->ID, 'linkedin_url', true )
: '';
$linkedin = $linkedin
? '<a href="' . esc_url( $linkedin ) . '" target="_blank" rel="noopener">LinkedIn</a>'
: '';
return sprintf(
'<div %s>%s<div class="my-author-box__body"><h3 class="my-author-box__name">%s</h3>%s<div class="my-author-box__links">%s%s</div></div></div>',
$wrapper, $avatar,
esc_html( $user->display_name ),
$bio, $email, $linkedin
);
},
)
);
} );
Known Limitations
These are not bugs — they are deliberate scope decisions for the initial implementation:
| Limitation | Notes |
|---|---|
| No inner blocks | autoRegister blocks use ServerSideRender. Inner blocks require client-side registration. |
| No image / file / rich-text controls | Auto-generated controls support string, integer, boolean, and enum only. The feature may expand as the Fields API matures in Gutenberg. |
| Editor preview is not live-reactive | Attribute changes trigger a server round-trip. For highly interactive blocks, JavaScript registration is still the right tool. |
wp_enqueue_block_style() does not apply in editor | Use the enqueue_block_editor_assets workaround described above. |
blockGap spacing support has known issues | See Gutenberg #69323. |
| Border double-rendering in older Gutenberg | Fixed in Gutenberg 21.9 via PR #72039. |
Who Is This For?
This feature is a perfect fit if you:
- Build theme-specific blocks where the design is fixed and server-driven
- Work with classic themes or server-side rendering workflows
- Are already comfortable with PHP but don’t want to maintain a JavaScript build pipeline
- Build simple display blocks: author boxes, CTA banners, marquees, heading styles, notices, pull quotes, custom cards
It is not a replacement for JavaScript-registered blocks when you need:
- Inline rich-text editing inside the block canvas
- Real-time reactive UI (sliders, colour pickers that update the preview immediately, drag-and-drop)
- Inner blocks / nesting
What I Built With It
While exploring this feature before the WordPress 7.0 release (via the Gutenberg plugin), I built four production-quality blocks using only PHP:
- Marquee — a scrolling text row with speed, direction, typography, and colour controls. The
requestAnimationFrameanimation script is registered atinitand enqueued lazily from the render callback so it only loads on pages that contain the block. - Call to Action — a banner block with heading, subheading, button label/URL, three visual variants (primary, secondary, dark), and native background/text colour overrides.
- Author Box — pulls user data, avatar, bio, email, and a custom LinkedIn URL (stored in user meta). The user selector is a dynamically-built
enumfromget_users(), giving a SelectControl populated at registration time. - Advanced Heading — 13 decorative heading styles driven by CSS, with a heading level selector (H1–H6), tagline field, and full typography/spacing/border support. Accent decorations on
::before/::afterpseudo-elements usevar(--mdlui-accent, currentColor)so they automatically follow the text colour set in the color panel.
All four blocks are in a single plugin with opt-in module toggles, share one consolidated CSS file (blocks.css), and required zero JavaScript registration code.
The Bottom Line
PHP-only block registration does not promise to replace JavaScript-registered blocks. What it does promise is to bring a large portion of the WordPress developer community — those of us who built things with PHP for twenty years — back into the block editor conversation.
For server-rendered blocks, display components, and theme-specific design elements, this is the cleanest authoring experience the block editor has ever offered. You write PHP. You get a block. No tooling. No build. No context switching.
WordPress 7.0 is not out yet at the time of writing, but you can use this feature today via the Gutenberg plugin (available since version 21.8, stabilised in 21.9).
References
| Resource | URL |
|---|---|
| Official dev note (WordPress 7.0) | https://make.wordpress.org/core/2026/03/03/php-only-block-registration/ |
| Core Trac ticket #64639 | https://core.trac.wordpress.org/ticket/64639 |
| Gutenberg PR #71794 (initial implementation) | https://github.com/WordPress/gutenberg/pull/71794 |
| Gutenberg PR #72039 (block supports fix) | https://github.com/WordPress/gutenberg/pull/72039 |
| Gutenberg PR #75543 (stabilisation) | https://github.com/WordPress/gutenberg/pull/75543 |
| wordpress-develop PR #10932 (core backport) | https://github.com/WordPress/wordpress-develop/pull/10932 |