Dynamic WordPress Blocks with register_block_type and PHP Render Callbacks

Dynamic blocks in WordPress render their output via a PHP callback at request time, making them ideal for content that changes frequently — recent posts, live counts, or user-specific data. Unlike static blocks, the block content is never stored in post_content.

Problem: A Gutenberg block needs to render fresh server-side data — post counts, dynamic listings, live widget output — but the static save function stores HTML at save time and becomes stale immediately.

Solution: Register a dynamic block by omitting the save function and providing a PHP render_callback in register_block_type(). The callback receives the block's attributes and inner content, executes fresh PHP on every page request, and uses get_block_wrapper_attributes() to output the correct wrapper with editor-generated classes.

The examples below register a dynamic block with register_block_type, write a PHP render callback that queries the database, and show the corresponding block.json with attributes and supports.

// register-block.php — called from your plugin's main file
function myplugin_register_latest_posts_block(): void {
    register_block_type(
        __DIR__ . '/blocks/latest-posts', // path to folder containing block.json
        [
            'render_callback' => 'myplugin_render_latest_posts',
        ]
    );
}
add_action( 'init', 'myplugin_register_latest_posts_block' );

function myplugin_render_latest_posts( array $attributes, string $content, WP_Block $block ): string {
    $count    = absint( $attributes['postsCount'] ?? 5 );
    $category = absint( $attributes['categoryId'] ?? 0 );

    $query_args = [
        'post_type'      => 'post',
        'post_status'    => 'publish',
        'posts_per_page' => $count,
        'cat'            => $category ?: null,
        'no_found_rows'  => true,
    ];

    $posts = get_posts( $query_args );
    if ( empty( $posts ) ) return '';

    $wrapper_attrs = get_block_wrapper_attributes( [ 'class' => 'latest-posts-block' ] );

    $html = "
    "; foreach ( $posts as $post ) { $html .= sprintf( '
  • %s
  • ', esc_url( get_permalink( $post ) ), esc_html( get_the_title( $post ) ) ); } $html .= '
'; return $html; }

The matching block.json with attributes:

{
  "apiVersion": 3,
  "name": "myplugin/latest-posts",
  "title": "Latest Posts",
  "category": "widgets",
  "icon": "list-view",
  "description": "Displays the most recent posts, optionally filtered by category.",
  "attributes": {
    "postsCount": {
      "type": "integer",
      "default": 5,
      "minimum": 1,
      "maximum": 20
    },
    "categoryId": {
      "type": "integer",
      "default": 0
    }
  },
  "supports": {
    "html": false,
    "align": ["wide", "full"],
    "color": { "background": true, "text": true },
    "spacing": { "padding": true, "margin": true }
  },
  "editorScript": "file:./index.js",
  "style": "file:./style.css"
}

NOTE: Use get_block_wrapper_attributes() in the render callback to automatically output the class, style, and id attributes that the block editor adds — this ensures color/spacing supports work correctly on the front end.

Leave Comment

Your email address will not be published. Required fields are marked *