Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 41 of 45

Dynamic blocks rendered with PHP

Build a block that PHP renders on every request with a render file, attributes and get_block_wrapper_attributes, for latest posts and live data.

15 minCustom blocksUpdated 23 October 2026

By the end of this lessonCreate a dynamic block with attributes that renders recent recipes on the server, escape its output, and know when to choose dynamic over static.

Static versus dynamic

In the previous lesson the block's save function produced HTML stored in the post. That is a static block, and it is perfect for fixed content. It is wrong for content that changes: a list of the latest recipes, today's date, a form that needs a nonce, content that depends on the visitor. The stored HTML would go stale, and every change to the markup would break existing posts.

A dynamic block stores only its attributes. Its HTML is produced by PHP on each page view. The JavaScript save returns null.

block.json for a dynamic block

This block shows the newest recipes. It uses a custom post type like the one from Custom post types, registered here to keep the lesson self-contained:

json
{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "bakery/latest-recipes",
	"title": "Latest recipes",
	"category": "widgets",
	"icon": "list-view",
	"description": "Shows the newest recipes.",
	"textdomain": "bakery-latest-recipes",
	"attributes": {
		"count": { "type": "number", "default": 3 },
		"showDate": { "type": "boolean", "default": false }
	},
	"supports": {
		"html": false,
		"align": [ "wide", "full" ],
		"spacing": { "margin": true, "padding": true }
	},
	"editorScript": "file:./index.js",
	"render": "file:./render.php"
}

"render": "file:./render.php" tells WordPress to run that PHP file whenever the block is displayed.

The PHP render file

Three variables are available inside it: $attributes (the block's attributes, with defaults filled in), $content (inner blocks' HTML, empty here) and $block (the WP_Block object).

php
<?php
/**
 * Renders the Latest recipes block.
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Inner blocks.
 * @var WP_Block $block      The block instance.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

$count = max( 1, min( 12, (int) $attributes['count'] ) );   // never trust attribute values

$recipes = get_posts( [
	'post_type'      => 'recipe',
	'posts_per_page' => $count,
	'orderby'        => 'date',
	'order'          => 'DESC',
	'no_found_rows'  => true,
] );

if ( ! $recipes ) {
	return;   // print nothing when there is nothing to show
}
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
	<ul class="bakery-latest-recipes">
		<?php foreach ( $recipes as $recipe ) : ?>
			<li>
				<a href="<?php echo esc_url( get_permalink( $recipe ) ); ?>"><?php echo esc_html( get_the_title( $recipe ) ); ?></a>
				<?php if ( ! empty( $attributes['showDate'] ) ) : ?>
					<time datetime="<?php echo esc_attr( get_the_date( 'c', $recipe ) ); ?>"><?php echo esc_html( get_the_date( 'j F Y', $recipe ) ); ?></time>
				<?php endif; ?>
			</li>
		<?php endforeach; ?>
	</ul>
</div>

Notes:

  • get_block_wrapper_attributes() prints the wrapper's class and style attributes: the generated class wp-block-bakery-latest-recipes, plus anything from supports (spacing, alignment) and the editor's "Additional CSS class". It is the PHP twin of useBlockProps.save().
  • Everything printed is escaped: esc_url(), esc_html(), esc_attr(). Attributes come from post content that editors write, so treat them as input.
  • return; in a render file means "no output".

Registering and the editor script

php
<?php
/**
 * Plugin Name: Bakery Latest Recipes
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

add_action( 'init', function () {
	register_post_type( 'recipe', [
		'label'        => 'Recipes',
		'public'       => true,
		'supports'     => [ 'title', 'editor' ],
		'show_in_rest' => true,
	] );
	register_block_type( __DIR__ );
} );

The JavaScript has less to do. edit shows a preview made by the server through the ServerSideRender component, plus controls for the attributes, and save returns null:

php
<?php
return [
	'dependencies' => [ 'wp-blocks', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-server-side-render' ],
	'version'      => '1.0.0',
];
javascript
( function ( blocks, blockEditor, components, element, ServerSideRender ) {
	const el = element.createElement;
	const { useBlockProps, InspectorControls } = blockEditor;
	const { PanelBody, RangeControl, ToggleControl } = components;

	blocks.registerBlockType( 'bakery/latest-recipes', {
		edit: function ( props ) {
			const { attributes, setAttributes } = props;
			return el(
				'div',
				useBlockProps(),
				el(
					InspectorControls,
					null,
					el(
						PanelBody,
						{ title: 'Settings' },
						el( RangeControl, {
							label: 'Number of recipes',
							value: attributes.count,
							min: 1,
							max: 12,
							onChange: function ( count ) {
								setAttributes( { count: count } );
							},
						} ),
						el( ToggleControl, {
							label: 'Show dates',
							checked: attributes.showDate,
							onChange: function ( showDate ) {
								setAttributes( { showDate: showDate } );
							},
						} )
					)
				),
				el( ServerSideRender, { block: 'bakery/latest-recipes', attributes: attributes } )
			);
		},

		// Dynamic: nothing is stored, PHP renders on the front end.
		save: function () {
			return null;
		},
	} );
} )( window.wp.blocks, window.wp.blockEditor, window.wp.components, window.wp.element, window.wp.serverSideRender );
bash
wp plugin activate bakery-latest-recipes
wp post create --post_type=recipe --post_title="Rye bread" --post_status=publish --post_date="2026-02-01 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Sourdough loaf" --post_status=publish --post_date="2026-02-05 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Focaccia" --post_status=publish --post_date="2026-02-10 09:00:00" --porcelain
wp post create --post_type=recipe --post_title="Ciabatta" --post_status=publish --post_date="2026-02-15 09:00:00" --porcelain
result
Plugin 'bakery-latest-recipes' activated.
Success: Activated 1 of 1 plugins.
4
5
6
7

What is stored, and what visitors see

An editor adds the block, sets the number to 2 and turns on dates. The post stores only the comment, with no HTML:

php
<?php
$content = '<!-- wp:bakery/latest-recipes {"count":2,"showDate":true} /-->';
echo $content, "\n\n";

// What visitors get: WordPress renders the block with PHP.
echo do_blocks( $content ), "\n";
result
<!-- wp:bakery/latest-recipes {"count":2,"showDate":true} /-->

<div class="wp-block-bakery-latest-recipes">
	<ul class="bakery-latest-recipes">
					<li>
				<a href="http://localhost:8080/recipe/ciabatta/">Ciabatta</a>
									<time datetime="2026-02-15T09:00:00+00:00">15 February 2026</time>
							</li>
					<li>
				<a href="http://localhost:8080/recipe/focaccia/">Focaccia</a>
									<time datetime="2026-02-10T09:00:00+00:00">10 February 2026</time>
							</li>
			</ul>
</div>

The self-closing comment <!-- ... /--> is the whole saved block. The block can be changed in the plugin at any time (new markup, new classes) and every existing post updates, because nothing was stored to compare against.

On a real page, it appears wherever the editor placed it:

php
<?php
wp_insert_post( [
	'post_title'   => 'Fresh from the oven',
	'post_name'    => 'fresh',
	'post_status'  => 'publish',
	'post_type'    => 'page',
	'post_content' => '<!-- wp:bakery/latest-recipes {"count":3} /-->',
] );
http
GET /fresh/
result
/recipe/ciabatta/">Ciabatta
/recipe/focaccia/">Focaccia
/recipe/sourdough-loaf/">Sourdough loaf

render_callback instead of a file

For small blocks you can register the PHP as a function instead of a file. The callback receives the same three values and returns the HTML as a string:

php
register_block_type( __DIR__, [
	'render_callback' => function ( array $attributes, string $content, WP_Block $block ): string {
		return '<p ' . get_block_wrapper_attributes() . '>' . esc_html( gmdate( 'Y' ) ) . '</p>';
	},
] );

Inner blocks

If the block contains other blocks (like a Group does), keep their HTML with <InnerBlocks /> in edit, <InnerBlocks.Content /> in save, and print $content in the render file. That makes a dynamic wrapper around static content.

Dynamic or static?

Static Dynamic
Output stored in the post generated by PHP on each view
Speed fastest a little PHP work per view (cache with transients if heavy)
Changing the markup later needs deprecations automatic
Best for fixed content: callouts, buttons, layouts changing data: latest posts, forms, user-specific content

Common mistakes

  • Unescaped attributes in the render file.
  • Forgetting get_block_wrapper_attributes(), so supports do nothing.
  • Heavy queries on every view without caching (see Caching with transients).
  • Returning null from save in a block that has no PHP renderer: the block shows nothing.
  • Forgetting "render" in block.json.

Exercise

Extend the render file to show the recipes' excerpts, but only on single pages (hint: is_singular()), cut to 15 words.

Show one possible answer
php
<?php if ( is_singular() ) : ?>
	<p><?php echo esc_html( wp_trim_words( get_the_excerpt( $recipe ), 15 ) ); ?></p>
<?php endif; ?>

Add it inside the <li>, after the link. get_the_excerpt( $recipe ) takes the post, and esc_html() escapes the result.

Key points

  • A dynamic block returns null from its JavaScript save function and renders its HTML in PHP, on each page view.
  • Use a render file in block.json ("render": "file:./render.php") or a render_callback; WordPress passes $attributes, $content and $block.
  • Choose dynamic for content that changes (latest posts, user-specific, data from a database); static blocks are for fixed content.
  • Escape everything in the PHP render file, use get_block_wrapper_attributes() for the wrapper, and use ServerSideRender or a placeholder for the editor preview.

Check yourself

1. How does a block become dynamic?
2. What are the arguments of a render file?
3. What does get_block_wrapper_attributes() return?
4. Why is a dynamic block safer against 'invalid content' errors?
5. Where should user-provided attribute values be escaped?