The Block Editor

How the block editor stores content as HTML comment delimiters inside post_content, and how that differs from the classic editor.

Syntax<!-- wp:namespace/name {"attr":value} --> ... <!-- /wp:namespace/name -->

The block editor, developed under the codename Gutenberg, became the default post editing screen in WordPress 5.0. It is a React application running in the admin that talks to the REST API. PHP never sees the editor; it only sees what was saved.

Blocks are still one column of HTML

There is no blocks table. post_content is still a single string. Each block is wrapped in HTML comment delimiters, with its attributes as JSON inside the opening comment:

<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center">Hello.</p>
<!-- /wp:paragraph -->

wp:paragraph is shorthand for wp:core/paragraph — the core/ namespace is dropped when core blocks are serialized, and both forms parse to the same block. Third-party blocks always carry their namespace: wp:my-plugin/notice. A block that saves no markup of its own uses the self-closing form, which means the same as an empty open/close pair: <!-- wp:my-plugin/latest {"count":4} /-->.

What that buys you

Because the delimiters are comments, the stored content is still valid HTML. Deactivate the plugin providing a block and the last-saved markup keeps rendering on the front end; only the editing UI disappears. On output, do_blocks() runs over the_content, leaves static markup alone, and calls the render callback for dynamic blocks.

Where people get caught

The parser compares the markup a block's save function produces now against what is in the database. Change that output and existing posts report "this block contains unexpected or invalid content".

Example

Example · php
/**
 * Collect the attachment IDs of every core/image block in a post.
 *
 * parse_blocks() reads the comment delimiters only. It does not render
 * anything and does not run any block's PHP.
 */
function myplugin_get_image_ids( int $post_id ): array {
	$post = get_post( $post_id );

	// has_blocks() is false for content saved by the classic editor,
	// which has no delimiters to parse.
	if ( ! $post instanceof WP_Post || ! has_blocks( $post->post_content ) ) {
		return array();
	}

	$ids = array();

	foreach ( parse_blocks( $post->post_content ) as $block ) {
		// blockName is null for the loose whitespace between top-level blocks.
		if ( 'core/image' === $block['blockName'] && isset( $block['attrs']['id'] ) ) {
			$ids[] = (int) $block['attrs']['id'];
		}
	}

	return $ids;
}

When to use it

  • Auditing which blocks a site actually uses before removing a plugin
  • Extracting the first image or heading from a post without rendering it
  • Explaining to a team why a block "broke" after a plugin update changed its markup

More examples

What a post actually contains

The raw post_content of a short post. Note the three shapes: a static block with its markup inline, a dynamic block written self-closing because it saved no markup, and a container block whose children sit between its delimiters. Read this when you are debugging why the front end and the editor disagree — the database is the source of truth and it is readable.

Example · html
<!-- wp:heading -->
<h2 class="wp-block-heading">Notes</h2>
<!-- /wp:heading -->

<!-- wp:paragraph -->
<p>Static block: the markup below the comment is what gets served.</p>
<!-- /wp:paragraph -->

<!-- wp:my-plugin/latest-posts {"count":4,"showDate":true} /-->

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
  <!-- wp:paragraph -->
  <p>Nested blocks are just nested comments.</p>
  <!-- /wp:paragraph -->
</div>
<!-- /wp:group -->

Rendering one block on its own

parse_blocks() plus render_block() lets you render a single block outside the_content — useful for building teasers, or for a REST field that returns just the intro. Do not concatenate innerHTML by hand instead: dynamic blocks have no innerHTML, so you would silently drop them.

Example · php
/**
 * Render just the first block of a post, for a card or an archive teaser.
 */
function myplugin_first_block_html( int $post_id ): string {
	$post = get_post( $post_id );

	if ( ! $post instanceof WP_Post ) {
		return '';
	}

	foreach ( parse_blocks( $post->post_content ) as $block ) {
		if ( null === $block['blockName'] ) {
			continue; // Whitespace between blocks.
		}

		// render_block() runs the block's render callback if it is dynamic,
		// and returns the saved innerHTML if it is static.
		return render_block( $block );
	}

	return '';
}

Discussion

  • Be the first to comment on this lesson.