How to migrate from the classic editor to blocks with mapping in PHP in WordPress

Contents

Introduction

This article is a comprehensive, step-by-step tutorial that shows how to migrate content from the Classic Editor (post_content with HTML and shortcodes) to Gutenberg blocks by mapping existing structures into block markup using PHP. You will learn strategies, safe practices, multiple mapping patterns (shortcodes, raw HTML, post meta, attachments, nested structures), and concrete code examples you can run as a plugin or via WP-CLI. Every code example is included so you can copy and adapt it directly.

Prerequisites and safety

  • Back up the site and database — Always take a full DB and files backup before running automated migrations.
  • Work on staging — Verify conversion results on a staging copy first.
  • Disable caching and object caches during conversion so results are immediately visible and not cached.
  • Plugin vs WP-CLI — Prefer a WP-CLI command for large migrations (it runs from the command line and avoids timeouts). A plugin activation task may work for small blogs but can hit memory/time limits.
  • Test incrementally — Run conversions in small batches or on a subset of posts with a dry-run mode.

Overview of the approach

The migration flow usually follows these steps:

  1. Scan posts that need conversion (WP_Query or custom SQL)
  2. Parse the current post_content and/or read post meta attachments
  3. Detect patterns to convert (shortcodes, specific HTML, meta-based components)
  4. Build equivalent block arrays (blockName, attrs, innerBlocks, innerHTML, innerContent)
  5. Serialize those block arrays into block markup (HTML comment block format)
  6. Replace or rewrite post_content and update posts
  7. Log results and optionally revert if problems occur

Key WordPress functions and utilities

  • parse_blocks(content) — parse content into an array of block structures (recognizes existing blocks and raw HTML).
  • serialize_blocks(blocks) and serialize_block(block) — convert block arrays back into a string containing block comments and inner HTML.
  • shortcode_parse_atts() and get_shortcode_regex() — useful to locate and parse shortcodes in content.
  • WP_Query, wp_update_post(), update_post_meta() — for fetching and writing post changes.
  • wp_insert_post() if creating new posts during migration.
  • wp_get_attachment_image_src() and wp_get_attachment_metadata() — to map attachments into block attributes (e.g., core/image or core/gallery).

General strategies for mapping

  • Shortcode -> Block: Parse the shortcode attributes and convert to block attributes. Generate a block array for that blockName and attributes.
  • Raw HTML -> Corresponding core block: Use DOMDocument or regex to extract content, then build a block with innerHTML or innerBlocks.
  • Meta -> Block attribute: Read post meta, embed the meta value as an attribute in a new block.
  • Nested structures: Build nested innerBlocks arrays (e.g., core/columns -> core/column -> inner blocks).
  • Preserve semantics: If the original content has classes, captions, alignment, convert those to block attributes where possible.
  • Dry-run and logging: Provide a mode that doesnt write DB changes but reports the planned output.

Example: Simple conversion flow (plugin skeleton)

The following example provides a plugin-like function to migrate posts in batches. It demonstrates fetching posts, converting shortcodes or specific HTML segments to blocks, and updating post_content. This is a simplified skeleton suitable for small sites or as a foundation for WP-CLI implementation.

lt?php
// Example: simple conversion batch runner (skeleton)
// NOTE: Do not attach heavy operations to init this illustrates logic.
function my_migrate_classic_to_blocks_batch( args = array() ) {
    defaults = array(
        post_type => post,
        posts_per_page => 20,
        paged => 1,
        dry_run => true, // set to false to commit
    )
    args = wp_parse_args( args, defaults )

    q = new WP_Query( array(
        post_type => args[post_type],
        posts_per_page => args[posts_per_page],
        paged => args[paged],
        post_status => array(publish,draft,private),
    ) )

    while ( q->have_posts() ) {
        q->the_post()
        post_id = get_the_ID()
        content = get_the_content(null, false)
        original = content

        // Step: parse content into blocks
        blocks = parse_blocks( content )

        // Example: walk over blocks array and transform raw HTML or shortcodes
        new_blocks = array()
        foreach ( blocks as block ) {
            if ( empty( block[blockName] ) ) {
                // raw HTML or paragraphs -> maybe contains legacy patterns
                raw = block[innerHTML]

                // Example: convert [my_special] shortcode to a custom block
                if ( strpos( raw, [my_special ) !== false ) {
                    // Extract attributes with regex or shortcode parser
                    // (Well show a robust shortcode->block example later)
                    converted_block = my_convert_my_special_shortcode_to_block( raw, post_id )
                    if ( converted_block ) {
                        new_blocks[] = converted_block
                        continue
                    }
                }

                // Example: convert inline 
to core/quote if ( strpos( raw, ltblockquote ) !== false strpos( raw, ltblockquote ) !== false ) { quote_block = my_convert_blockquote_html_to_block( raw ) if ( quote_block ) { new_blocks[] = quote_block continue } } // No conversion, keep raw as a core/html block new_blocks[] = block } else { // Already a block (core/paragraph, etc.) - keep or inspect nested content new_blocks[] = block } } new_content = serialize_blocks( new_blocks ) if ( new_content !== original ) { if ( args[dry_run] ) { // Log the difference somewhere, here we output to error log as example error_log( Post {post_id} would be updated. Old length: . strlen(original) . New length: . strlen(new_content) ) } else { // Commit the change wp_update_post( array( ID => post_id, post_content => new_content, ) ) error_log( Post {post_id} updated by migration. ) } } } wp_reset_postdata() } ?gt

Example: Converting gallery shortcode to core/gallery block

Shortcodes often represent gallery, sliders, or custom components. Heres a concrete transformation from into a core/gallery block. The example extracts attachment IDs and constructs the block array, then serializes it to a block string.

lt?php
// Convert gallery shortcode text to a core/gallery block array
function my_convert_gallery_shortcode_to_block_array( shortcode_string ) {
    // Find gallery shortcodes in the string
    pattern = get_shortcode_regex( array(gallery) )
    if ( preg_match_all( /.pattern./s, shortcode_string, matches, PREG_SET_ORDER ) ) {
        blocks = array()
        foreach ( matches as m ) {
            // m[0] = full shortcode, m[3] = attributes string
            attr_string = isset( m[3] ) ? m[3] : 
            attrs = shortcode_parse_atts( attr_string )
            ids = array()

            if ( ! empty( attrs[ids] ) ) {
                ids = array_map( intval, explode( ,, attrs[ids] ) )
            } else {
                // If no ids, WordPress core gallery uses attached images for the post
                // For migration you may skip or try to detect by searching for gallery markup
            }

            gallery_block = array(
                blockName => core/gallery,
                attrs => array(
                    ids => ids,
                    // map other attributes: columns, sizeSlug, linkTo, etc.
                    columns => isset( attrs[columns] ) ? intval( attrs[columns] ) : 3,
                    linkTo => isset( attrs[link] )  attrs[link] === file ? media : post,
                ),
                innerBlocks => array(),
                innerHTML => , // gallery block uses innerHTML for fallback markup keeping empty is fine
                innerContent => array(),
            )

            blocks[] = gallery_block
        }
        return blocks
    }
    return array()
}

// Example usage within a block processing loop:
gallery_blocks = my_convert_gallery_shortcode_to_block_array( raw_html_or_content )
foreach ( gallery_blocks as gb ) {
    serialized = serialize_block( gb )
    // You can replace the matched shortcode with serialized in your content
}
?gt

Example: Converting raw HTML ltblockquotegt to core/quote

Legacy posts may contain block-level HTML such as ltblockquotegt. Use DOMDocument to extract and map to core/quote blocks. This is safer than regex for complex HTML.

lt?php
function my_convert_blockquote_html_to_block( raw_html ) {
    // raw_html may come encoded by parse_blocks (innerHTML often contains entities)
    // Decode HTML entities for DOMDocument parsing
    decoded = html_entity_decode( raw_html, ENT_QUOTES  ENT_HTML5, UTF-8 )

    // Use DOMDocument to extract blockquote content
    dom = new DOMDocument()
    // Suppress errors for malformed HTML
    @dom->loadHTML(  . decoded )

    blockquotes = dom->getElementsByTagName( blockquote )
    if ( blockquotes->length === 0 ) {
        return null
    }

    // Create blocks for each blockquote discovered
    blocks = array()
    foreach ( blockquotes as node ) {
        // Get innerHTML of node
        inner = 
        foreach ( node->childNodes as child ) {
            inner .= dom->saveHTML( child )
        }

        // Build a core/quote block
        quote_block = array(
            blockName => core/quote,
            attrs => array(), // you could add citations if you detect 
            innerBlocks => array(),
            innerHTML => inner,
            innerContent => array( inner ),
        )
        blocks[] = quote_block
    }

    // If multiple blockquotes found, you may return a wrapper or the first one
    return count( blocks ) === 1 ? blocks[0] : blocks
}
?gt

Example: Complex nested mapping — Columns and inner blocks

When converting column-like HTML structures (for example, a two-column layout built with markup or shortcodes), you need to build nested innerBlocks arrays. This example creates a core/columns block that contains two core/column blocks with nested core/paragraph blocks.

lt?php
function build_columns_block_from_parts( left_html, right_html ) {
    // left_html and right_html are HTML fragments to become inner blocks
    left_paragraph_block = array(
        blockName => core/paragraph,
        attrs => array(),
        innerBlocks => array(),
        innerHTML => left_html,
        innerContent => array( left_html ),
    )

    right_paragraph_block = array(
        blockName => core/paragraph,
        attrs => array(),
        innerBlocks => array(),
        innerHTML => right_html,
        innerContent => array( right_html ),
    )

    left_column = array(
        blockName => core/column,
        attrs => array(width => 50%),
        innerBlocks => array( left_paragraph_block ),
        innerHTML => ,
        innerContent => array(),
    )

    right_column = array(
        blockName => core/column,
        attrs => array(width => 50%),
        innerBlocks => array( right_paragraph_block ),
        innerHTML => ,
        innerContent => array(),
    )

    columns_block = array(
        blockName => core/columns,
        attrs => array(),
        innerBlocks => array( left_column, right_column ),
        innerHTML => ,
        innerContent => array(),
    )

    return columns_block
}
?gt

Mapping post meta to block attributes

Some legacy patterns use post meta for structured content (for example, subtitle, author bio, or CTA text). You can read that meta and insert a new block that carries the meta value as a block attribute.

lt?php
function my_mapping_meta_to_block( post_id ) {
    subtitle = get_post_meta( post_id, _my_subtitle, true )
    if ( empty( subtitle ) ) {
        return null
    }
    block = array(
        blockName => myplugin/special-header,
        attrs => array(
            subtitle => subtitle,
        ),
        innerBlocks => array(),
        innerHTML => ,
        innerContent => array(),
    )
    return block
}

// Usage: insert this block at top of post content
meta_block = my_mapping_meta_to_block( post_id )
if ( meta_block ) {
    all_blocks = parse_blocks( post_content )
    array_unshift( all_blocks, meta_block ) // put meta block first
    new_content = serialize_blocks( all_blocks )
    wp_update_post( [ ID => post_id, post_content => new_content ] )
}
?gt

Converting custom shortcodes to dynamic blocks with attributes

If you have custom shortcodes that produce complex output, you can convert them into custom blocks. Use PHP to parse shortcode attributes and content, and produce a block array that maps those attributes. Register the new block type in JS/PHP (so the editor can display and edit it) and make the server rendering available if needed.

lt?php
// Example: convert [cta text=Buy now url=/buy] to myplugin/cta block
function my_convert_cta_shortcode_to_block_array( text ) {
    pattern = get_shortcode_regex( array(cta) )
    if ( preg_match_all( /.pattern./s, text, matches, PREG_SET_ORDER ) ) {
        blocks = array()
        foreach ( matches as m ) {
            attr_string = isset( m[3] ) ? m[3] : 
            attrs = shortcode_parse_atts( attr_string )

            cta_block = array(
                blockName => myplugin/cta,
                attrs => array(
                    text => isset( attrs[text] ) ? attrs[text] : ,
                    url  => isset( attrs[url] ) ? attrs[url] : ,
                ),
                innerBlocks => array(),
                innerHTML => , 
                innerContent => array(),
            )
            blocks[] = cta_block
        }
        return blocks
    }
    return array()
}
?gt

Serializing blocks and replacing content

Once you have created block arrays, use serialize_block or serialize_blocks to produce the final markup. If youre replacing particular matched substrings (like shortcodes) with block markup inside raw HTML parts, be sure to replace only the matched range to preserve other content. Keep a dry-run to confirm results.

lt?php
// Example: replace gallery shortcodes with serialized gallery blocks in a string
function replace_gallery_shortcodes_with_blocks( content ) {
    pattern = get_shortcode_regex( array(gallery) )
    return preg_replace_callback( /.pattern./s, function( m ) {
        attr_string = isset( m[3] ) ? m[3] : 
        attrs = shortcode_parse_atts( attr_string )
        ids = ! empty( attrs[ids] ) ? array_map( intval, explode(,, attrs[ids]) ) : array()
        block = array(
            blockName => core/gallery,
            attrs => array( ids => ids ),
            innerBlocks => array(),
            innerHTML => ,
            innerContent => array(),
        )
        return serialize_block( block )
    }, content )
}
?gt

Batch processing with WP-CLI (recommended for large sites)

Large conversions should run via WP-CLI to avoid web timeouts. Below is a sample WP-CLI command function you can register in a small plugin file. It uses the conversion logic and outputs progress and summary. For brevity this example uses a single conversion function extend it to include dry-run and logging.

lt?php
// Register a WP-CLI command migrate-classic-to-blocks
if ( defined( WP_CLI )  WP_CLI ) {
    WP_CLI::add_command( migrate-classic-to-blocks, function( args, assoc_args ) {
        per_page = isset( assoc_args[per-page] ) ? intval( assoc_args[per-page] ) : 50
        paged = 1
        total_updated = 0
        dry_run = isset( assoc_args[dry-run] )

        while ( true ) {
            q = new WP_Query( array(
                post_type => post,
                posts_per_page => per_page,
                paged => paged,
                post_status => array(publish,draft,private),
            ) )

            if ( ! q->have_posts() ) {
                break
            }

            foreach ( q->posts as post ) {
                post_id = post->ID
                content = post->post_content
                new_content = replace_gallery_shortcodes_with_blocks( content )
                // More replacement functions can be applied in sequence

                if ( new_content !== content ) {
                    if ( dry_run ) {
                        WP_CLI::log( Dry-run: Post {post_id} would be updated. )
                    } else {
                        wp_update_post( array( ID => post_id, post_content => new_content ) )
                        WP_CLI::log( Updated post {post_id}. )
                    }
                    total_updated  
                }
            }

            paged  
        }

        WP_CLI::success( Done. Posts affected: {total_updated} )
    } )
}
?gt

Testing and verification

  • Open migrated posts in the Block Editor to confirm block structure shows correctly.
  • Inspect post_content in the DB to confirm correct block comment markup: … .
  • Test frontend rendering for various post statuses and templates.
  • Verify attachments, image sizes, alt text, captions, and links were mapped correctly.
  • Verify custom blocks are registered and available in the editor to edit their attributes.

Rollback plan

Always have a rollback plan: a DB export before running migration or support for creating a post_content backup meta for each migrated post so you can restore individual posts if conversion fails. Example: save original content to a meta key before update.

lt?php
// Save original content in a meta key before updating
original = post->post_content
update_post_meta( post_id, _migration_original_content, wp_slash( original ) )
// Then update post_content...
?gt

Edge cases and advanced tips

  • Shortcodes that produce dynamic content — If shortcodes rely on runtime context, consider converting them into dynamic server-rendered blocks that keep a reference to original parameters but render dynamically.
  • Serialized post meta / repeater fields — Carefully inspect unserialize() usage and convert PHP serialized arrays into JSON-based block attributes when needed.
  • Performance — Use object-cache-aware logic avoid loading large images unnecessarily process attachments in batches and update attachment metadata only if needed.
  • Block registration — Register any custom block types (myplugin/) in PHP and JS before conversion so the editor recognizes them and can render them properly in edit mode.
  • Validation — After migration, run unit or integration checks for templates that depend on specific markup.

Complete example: small migration plugin (structure)

Below is a compact but complete plugin-like script illustrating the major parts together: scan posts, convert gallery shortcodes and blockquotes, save original content meta and update post_content. Use WP-CLI variant for heavy workloads. This is a template — adapt to your site needs, extend mapping functions, and add dry-run/logging.

lt?php
/
Plugin Name: Classic to Blocks Migrator (example)
Description: Example migration helper. Adapt for production use.
Version: 0.1
/

// Core conversion entry (safe for CLI or called from admin with capability checks)
function c2b_migrate_posts_batch( args = array() ) {
    defaults = array(
        post_type => post,
        posts_per_page => 50,
        paged => 1,
        dry_run => true,
    )
    args = wp_parse_args( args, defaults )

    q = new WP_Query( array(
        post_type => args[post_type],
        posts_per_page => args[posts_per_page],
        paged => args[paged],
        post_status => array(publish,draft,private),
    ) )

    foreach ( q->posts as post ) {
        post_id = post->ID
        content = post->post_content
        new_content = content

        // Replace gallery shortcodes
        new_content = replace_gallery_shortcodes_with_blocks( new_content )

        // Convert blockquotes (simple DOM-based replacement)
        blocks = parse_blocks( new_content )
        out_blocks = array()
        foreach ( blocks as block ) {
            if ( empty( block[blockName] ) ) {
                converted = my_convert_blockquote_html_to_block( block[innerHTML] )
                if ( converted ) {
                    // If a single block returned, append if array, merge
                    if ( isset( converted[blockName] ) ) {
                        out_blocks[] = converted
                    } else {
                        out_blocks = array_merge( out_blocks, converted )
                    }
                    continue
                }
            }
            out_blocks[] = block
        }

        serialized = serialize_blocks( out_blocks )
        if ( serialized !== content ) {
            if ( args[dry_run] ) {
                error_log( Dry-run: Post {post_id} would be migrated. )
            } else {
                // Backup original content
                update_post_meta( post_id, _c2b_original_content, wp_slash( content ) )

                wp_update_post( array(
                    ID => post_id,
                    post_content => serialized,
                ) )
                error_log( Post {post_id} migrated. )
            }
        }
    }
    wp_reset_postdata()
}

// Utility replacement function included earlier
function replace_gallery_shortcodes_with_blocks( content ) {
    pattern = get_shortcode_regex( array(gallery) )
    return preg_replace_callback( /.pattern./s, function( m ) {
        attr_string = isset( m[3] ) ? m[3] : 
        attrs = shortcode_parse_atts( attr_string )
        ids = ! empty( attrs[ids] ) ? array_map( intval, explode(,, attrs[ids]) ) : array()
        block = array(
            blockName => core/gallery,
            attrs => array( ids => ids ),
            innerBlocks => array(),
            innerHTML => ,
            innerContent => array(),
        )
        return serialize_block( block )
    }, content )
}

// Reuse the my_convert_blockquote_html_to_block() function shown earlier
// (include it here or require from a helper file)
?gt

Final recommendations

  • Start small, verify results visually in the Block Editor and on the frontend.
  • Keep original content backups in post meta to allow easy per-post rollback.
  • Create idempotent migration scripts that can be re-run safely (skip posts already migrated or check meta flag).
  • Register custom blocks on both the editor (JS) and server (PHP) so migrated blocks can be edited by users.
  • Document the migration plan and map all shortcodes and HTML patterns used across the site before running the migration.

Useful links

Notes

This article gives practical code patterns and an approach you can adapt to your site. Test thoroughly, keep backups, and run on staging before touching production.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

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