Most content on a modern WordPress site is edited in the block editor, so a plugin that only offers shortcodes and classic meta boxes feels dated. This lesson covers the plugin side of block editor integration: how a plugin ships a block, how it makes its meta editable in the editor, and how it feeds its data into core blocks. Writing React components in depth is the job of the Gutenberg course; here you write just enough JavaScript to wire things up.
What a plugin ships
A block lives in a folder with a block.json file. The PHP side reads that file and registers the block; the JavaScript side is compiled into a build/ folder. A typical layout for the events plugin:
myplugin/
├── myplugin.php
├── includes/
│ └── blocks.php
├── assets/
│ ├── blocks/
│ │ └── upcoming-events/
│ │ ├── block.json
│ │ ├── index.js
│ │ └── render.php
│ └── editor/
│ └── index.js
├── build/ (generated, shipped with the plugin)
└── package.json (development only)
Sources go in assets/, not src/, because lesson 16 uses src/ for PHP classes. Users never run npm: you build before release and ship the build/ folder.
The build step
@wordpress/scripts wraps webpack and Babel with the configuration core uses. Install it as a development dependency:
npm init -y
npm install --save-dev @wordpress/scripts
Then add scripts to package.json:
{
"scripts": {
"build:blocks": "wp-scripts build --webpack-src-dir=assets/blocks --output-path=build/blocks",
"build:editor": "wp-scripts build assets/editor/index.js --output-path=build/editor",
"build": "npm run build:blocks && npm run build:editor",
"start": "wp-scripts start --webpack-src-dir=assets/blocks --output-path=build/blocks"
}
}
The block build finds every block.json under assets/blocks, compiles the files it references and copies block.json and render.php into build/blocks/upcoming-events/. Each compiled script gets an index.asset.php file listing its WordPress script dependencies and a version hash, which PHP uses when enqueuing.
A dynamic block
A dynamic block stores only its attributes in the post content and renders its HTML in PHP on every page view. That suits event listings, which change as dates pass. Here is the metadata:
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "myplugin/upcoming-events",
"title": "Upcoming events",
"category": "widgets",
"icon": "calendar-alt",
"description": "Lists the next events by date.",
"textdomain": "myplugin",
"attributes": {
"count": {
"type": "integer",
"default": 3
}
},
"supports": {
"html": false,
"align": [ "wide", "full" ]
},
"editorScript": "file:./index.js",
"render": "file:./render.php"
}
The render key points to a PHP template. WordPress includes it with three variables in scope: $attributes, $content and $block.
<?php
// myplugin/assets/blocks/upcoming-events/render.php
defined( 'ABSPATH' ) || exit;
$myplugin_count = max( 1, min( 10, (int) ( $attributes['count'] ?? 3 ) ) );
$myplugin_events = get_posts(
array(
'post_type' => 'myplugin_event',
'posts_per_page' => $myplugin_count,
'meta_key' => 'myplugin_event_date',
'orderby' => 'meta_value',
'order' => 'ASC',
'meta_query' => array(
array(
'key' => 'myplugin_event_date',
'value' => wp_date( 'Y-m-d' ),
'compare' => '>=',
'type' => 'DATE',
),
),
)
);
if ( ! $myplugin_events ) {
return;
}
?>
<ul <?php echo get_block_wrapper_attributes(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped by core. ?>>
<?php foreach ( $myplugin_events as $myplugin_event ) : ?>
<li>
<a href="<?php echo esc_url( get_permalink( $myplugin_event ) ); ?>">
<?php echo esc_html( get_the_title( $myplugin_event ) ); ?>
</a>
<time datetime="<?php echo esc_attr( get_post_meta( $myplugin_event->ID, 'myplugin_event_date', true ) ); ?>">
<?php echo esc_html( get_post_meta( $myplugin_event->ID, 'myplugin_event_date', true ) ); ?>
</time>
</li>
<?php endforeach; ?>
</ul>
The template runs in a function scope, but the variables still read as globals to coding standards tools, so they carry the prefix. get_block_wrapper_attributes() adds the block's class names and any support styles, such as alignment. Treat $attributes as untrusted input: they come from post content that any author can edit, so the template clamps count between 1 and 10.
The editor script registers the block on the JavaScript side. For a dynamic block, save returns null and the editor previews the server output:
// myplugin/assets/blocks/upcoming-events/index.js
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { PanelBody, RangeControl } from '@wordpress/components';
import ServerSideRender from '@wordpress/server-side-render';
import { __ } from '@wordpress/i18n';
import metadata from './block.json';
registerBlockType( metadata.name, {
edit( { attributes, setAttributes } ) {
return (
<>
<InspectorControls>
<PanelBody title={ __( 'Settings', 'myplugin' ) }>
<RangeControl
label={ __( 'Number of events', 'myplugin' ) }
value={ attributes.count }
onChange={ ( count ) => setAttributes( { count } ) }
min={ 1 }
max={ 10 }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
</PanelBody>
</InspectorControls>
<div { ...useBlockProps() }>
<ServerSideRender block={ metadata.name } attributes={ attributes } />
</div>
</>
);
},
save: () => null,
} );
Registering the block in PHP
One call per block, pointing at the built folder:
<?php
// myplugin/includes/blocks.php
defined( 'ABSPATH' ) || exit;
add_action( 'init', 'myplugin_register_blocks' );
function myplugin_register_blocks(): void {
register_block_type( MYPLUGIN_PATH . 'build/blocks/upcoming-events' );
}
register_block_type() reads block.json, registers the editor script with the dependencies from index.asset.php, wires up render.php and sets up translations for the script. Front-end assets declared in block.json load only on pages that contain the block, which is one reason blocks are lighter than shortcodes.
When a plugin ships many blocks, current versions of WordPress can register them all from a single generated manifest with wp_register_block_types_from_metadata_collection(), which avoids reading every block.json on each request. Check the @wordpress/scripts documentation for the matching build command before adopting it.
Exposing meta to the editor
The meta box lesson registered myplugin_event_date and myplugin_price with register_post_meta(). The block editor reads and saves meta only through the REST API, so three conditions must hold:
- each key is registered with
show_in_rest => trueand atype; - the post type is registered with
show_in_rest => true; - the post type supports
custom-fields, otherwise themetafield is missing from its REST responses.
<?php
// myplugin/includes/meta.php
defined( 'ABSPATH' ) || exit;
add_action( 'init', 'myplugin_register_event_meta' );
function myplugin_register_event_meta(): void {
$auth = static function ( $allowed, $meta_key, $post_id ) {
return current_user_can( 'edit_post', $post_id );
};
register_post_meta(
'myplugin_event',
'myplugin_event_date',
array(
'type' => 'string',
'single' => true,
'default' => '',
'show_in_rest' => true,
'auth_callback' => $auth,
'sanitize_callback' => static function ( $value ) {
$date = DateTime::createFromFormat( 'Y-m-d', (string) $value );
return $date ? $date->format( 'Y-m-d' ) : '';
},
)
);
register_post_meta(
'myplugin_event',
'myplugin_price',
array(
'type' => 'number',
'single' => true,
'default' => 0,
'show_in_rest' => true,
'auth_callback' => $auth,
'sanitize_callback' => static function ( $value ) {
return max( 0, round( (float) $value, 2 ) );
},
)
);
}
The auth_callback decides who may change the value through the API; WordPress passes more arguments than it uses, which a closure simply ignores. With the post type's supports including custom-fields, both keys appear under meta in /wp-json/wp/v2/events/<id>.
A document settings panel
Instead of a classic meta box, add a panel to the editor's sidebar. useEntityProp reads and writes the current post's meta, and the editor saves it with the post:
// myplugin/assets/editor/index.js
import { registerPlugin } from '@wordpress/plugins';
import { PluginDocumentSettingPanel, store as editorStore } from '@wordpress/editor';
import { TextControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { useEntityProp } from '@wordpress/core-data';
import { __ } from '@wordpress/i18n';
function EventDetailsPanel() {
const postType = useSelect( ( select ) => select( editorStore ).getCurrentPostType(), [] );
const [ meta, setMeta ] = useEntityProp( 'postType', postType, 'meta' );
if ( 'myplugin_event' !== postType ) {
return null;
}
return (
<PluginDocumentSettingPanel name="myplugin-event-details" title={ __( 'Event details', 'myplugin' ) }>
<TextControl
type="date"
label={ __( 'Date', 'myplugin' ) }
value={ meta?.myplugin_event_date ?? '' }
onChange={ ( value ) => setMeta( { ...meta, myplugin_event_date: value } ) }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
<TextControl
type="number"
label={ __( 'Price', 'myplugin' ) }
value={ meta?.myplugin_price ?? 0 }
onChange={ ( value ) => setMeta( { ...meta, myplugin_price: parseFloat( value ) || 0 } ) }
__next40pxDefaultSize
__nextHasNoMarginBottom
/>
</PluginDocumentSettingPanel>
);
}
registerPlugin( 'myplugin-event-details', { render: EventDetailsPanel } );
Load it only in the editor, with the generated dependency file:
<?php
// myplugin/includes/blocks.php (continued)
add_action( 'enqueue_block_editor_assets', 'myplugin_enqueue_editor_assets' );
function myplugin_enqueue_editor_assets(): void {
$asset_file = MYPLUGIN_PATH . 'build/editor/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$asset = require $asset_file;
wp_enqueue_script(
'myplugin-editor',
MYPLUGIN_URL . 'build/editor/index.js',
$asset['dependencies'],
$asset['version'],
true
);
wp_set_script_translations( 'myplugin-editor', 'myplugin', MYPLUGIN_PATH . 'languages' );
}
The sanitise callbacks in PHP still run when the editor saves, so the panel is a convenience, not a security boundary.
Block bindings: plugin data in core blocks
The Block Bindings API lets a core block, such as a paragraph, heading, image or button, take an attribute's value from a source instead of from its saved content. Core provides a core/post-meta source, so any meta registered with show_in_rest can be bound without new JavaScript:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"core/post-meta","args":{"key":"myplugin_event_date"}}}}} -->
<p>Event date</p>
<!-- /wp:paragraph -->
Raw meta is not always what visitors should see. A custom source formats it in PHP:
<?php
// myplugin/includes/bindings.php
defined( 'ABSPATH' ) || exit;
add_action( 'init', 'myplugin_register_binding_sources' );
function myplugin_register_binding_sources(): void {
register_block_bindings_source(
'myplugin/event',
array(
'label' => __( 'Event details', 'myplugin' ),
'get_value_callback' => 'myplugin_event_binding_value',
'uses_context' => array( 'postId' ),
)
);
}
function myplugin_event_binding_value( array $source_args, WP_Block $block_instance, string $attribute_name ): ?string {
$post_id = (int) ( $block_instance->context['postId'] ?? get_the_ID() );
if ( ! $post_id || 'myplugin_event' !== get_post_type( $post_id ) ) {
return null;
}
switch ( $source_args['field'] ?? '' ) {
case 'date':
$date = (string) get_post_meta( $post_id, 'myplugin_event_date', true );
return $date ? wp_date( get_option( 'date_format' ), strtotime( $date ) ) : null;
case 'price':
$price = (float) get_post_meta( $post_id, 'myplugin_price', true );
return $price > 0 ? number_format_i18n( $price, 2 ) : __( 'Free', 'myplugin' );
}
return null;
}
Bind a block with "source":"myplugin/event","args":{"field":"price"}. Combine bindings in a block pattern or a post type template and site builders get event layouts they can restyle without touching PHP. In the editor, a source registered only in PHP shows a placeholder; registering the same source in JavaScript lets the editor preview real values, which the Gutenberg course covers.
Summary
- A plugin ships blocks as folders with
block.json, builds them with@wordpress/scriptsand ships thebuild/output, not the sources. - Register each block with
register_block_type()pointing at its built folder; dynamic blocks render in arender.phptemplate that clamps attributes and escapes output. - The editor edits meta through REST, so register keys with
show_in_rest, atype, sanitising and anauth_callback, and give the post typecustom-fieldssupport. - Add editor panels with
registerPluginandPluginDocumentSettingPanel, enqueued onenqueue_block_editor_assetswith the generated asset file. - Block bindings put plugin data into core blocks:
core/post-metafor raw values,register_block_bindings_source()for formatted ones.