The Block Editor
How the block editor stores content as HTML comment delimiters inside post_content, and how that differs from the classic editor.
<!-- 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
/**
* 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.
<!-- 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.
/**
* 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