DEV Community

Cover image for The ACF Block.json Migration Matrix Nobody Published
Chris
Chris

Posted on

The ACF Block.json Migration Matrix Nobody Published

Search "acf_register_block_type to block.json mapping" and you get tutorials. Each one converts a single testimonial block and calls it done. None of them hand you the full key-by-key table: every legacy PHP argument, its block.json destination, and the handful of settings that have no destination at all.

I migrated forty-plus blocks across two client themes this year. Some keys moved with zero friction. Others sat in gray areas I only resolved by testing in the block editor and watching what broke. This is the matrix I wish existed before I started.

Why the gap exists

ACF's own documentation covers acf_register_block_type() on one page and the block.json acf key on another. Both pages are accurate. Neither cross-references the other. A developer migrating a block has to hold both pages open and manually match render_template to renderTemplate, post_types to postTypes, and so on. Miss one, and a block silently loses a feature instead of throwing an error.

Three categories of keys make this worse:

Renamed keys. Same feature, different casing, different location. snake_case becomes camelCase, and the key moves from the flat settings array into a nested acf object.

Relocated keys. Some legacy settings aren't ACF-specific at all. They belong to WordPress's core block registration and move to the top level of block.json, outside the acf object entirely.

Orphaned keys. A few legacy settings have no documented block.json equivalent. You either drop the behavior, replicate it with WordPress's native supports API, or handle it in your render template instead.

The full matrix

Legacy acf_register_block_type() key block.json location Notes
name Top-level name Must be namespaced, e.g. acf/testimonial instead of testimonial
title Top-level title Direct match
description Top-level description Direct match
category Top-level category Direct match
icon Top-level icon Direct match, including the array form for background/foreground colors
keywords Top-level keywords Direct match
post_types acf.postTypes Renamed and relocated
mode acf.mode Renamed and relocated
render_template acf.renderTemplate Renamed and relocated; path is now relative to block.json's own directory, not the theme root
render_callback acf.renderCallback Renamed and relocated
enqueue_style Top-level style Replace a single enqueued URL with a style array of file references
enqueue_script Top-level script Same pattern as style
enqueue_assets No equivalent Split into editorScript, editorStyle, script, style, viewScript depending on where the asset needs to load
align No equivalent Set a default via attributes.align.default in your own attribute schema, or handle it in the render template
supports.align supports.align Direct match; this one is genuine WordPress core supports
supports.align_text Undocumented Works if placed under supports, but ACF doesn't confirm this for block.json. Test before relying on it.
supports.align_content Undocumented Same caveat as align_text
supports.mode Undocumented Same caveat
supports.multiple Undocumented Same caveat
supports.full_height Undocumented Same caveat
supports.jsx Not needed JSX parsing for <InnerBlocks /> is automatic under block API v2 and higher, which block.json uses by default
example Top-level example Direct match, part of WordPress's core block.json schema
acf_block_version acf.blockVersion Renamed and relocated, still defaults to 2

block.json-only additions

These have no legacy PHP equivalent. They didn't exist when acf_register_block_type() was the only option, so there's nothing to migrate from. Worth knowing about anyway, because they replace patterns you may have hacked together in a render callback.

block.json acf key What it replaces
validate Manual validation logic you wrote inside a render callback (defaults to true)
usePostMeta Custom post-meta storage code for singleton blocks (limits the block to once per page, top-level placement only)
hideFieldsInSidebar CSS you wrote to hide the sidebar panel
autoInlineEditing Manual inline-editing JS for v3 blocks
expandedEditorButtonText A filter you used to rename the editor button
expandedEditorButtons Conditional logic to hide the sidebar button or toolbar icon
autoJsonLd / schemaType Manual <script type="application/ld+json"> output in your template (schemaType is inferred from field mappings if left unset)

The asset problem, in detail

enqueue_style and enqueue_script looked interchangeable with style and script on the surface. They aren't. The legacy keys accept one URL. The block.json keys accept an array of file references, and WordPress expects those references in one of two forms: a file: path relative to block.json, or a registered script/style handle.

Legacy:

acf_register_block_type(array(
    'name'            => 'testimonial',
    'title'           => __( 'Testimonial' ),
    'render_template' => 'template-parts/blocks/testimonial/testimonial.php',
    'enqueue_style'   => get_template_directory_uri() . '/template-parts/blocks/testimonial/testimonial.css',
    'enqueue_script'  => get_template_directory_uri() . '/template-parts/blocks/testimonial/testimonial.js',
    'category'        => 'formatting',
));
Enter fullscreen mode Exit fullscreen mode

block.json equivalent:

{
    "$schema": "https://advancedcustomfields.com/schemas/json/main/block.json",
    "name": "acf/testimonial",
    "title": "Testimonial",
    "category": "formatting",
    "style": ["file:./testimonial.css"],
    "script": ["file:./testimonial.js"],
    "acf": {
        "mode": "preview",
        "renderTemplate": "testimonial.php"
    }
}
Enter fullscreen mode Exit fullscreen mode

Register both files with register_block_type() pointing at the block.json path, and WordPress handles the enqueueing automatically, front and back end, without you calling wp_enqueue_style() or wp_enqueue_script() yourself.

enqueue_assets has no direct swap because it did two jobs at once: run arbitrary PHP on block render and let you conditionally load assets. Split those jobs. Static assets go in script/style/editorScript/editorStyle. Conditional logic that isn't asset loading belongs in your render template, guarded by $is_preview or $block['id'].

Full before-and-after

Here's the testimonial block from ACF's own documentation, converted key by key.

Before (functions.php):

add_action( 'acf/init', 'my_acf_blocks_init' );
function my_acf_blocks_init() {
    if ( function_exists( 'acf_register_block_type' ) ) {
        acf_register_block_type( array(
            'name'              => 'testimonial',
            'title'             => __( 'Testimonial' ),
            'description'       => __( 'A custom testimonial block.' ),
            'render_template'   => 'template-parts/blocks/testimonial/testimonial.php',
            'mode'              => 'preview',
            'category'          => 'formatting',
            'post_types'        => array( 'post', 'page' ),
            'acf_block_version' => 2,
            'supports'          => array(
                'align'    => true,
                'mode'     => false,
                'multiple' => false,
            ),
        ) );
    }
}
Enter fullscreen mode Exit fullscreen mode

After (blocks/testimonial/block.json):

{
    "$schema": "https://advancedcustomfields.com/schemas/json/main/block.json",
    "apiVersion": 2,
    "name": "acf/testimonial",
    "title": "Testimonial",
    "description": "A custom testimonial block.",
    "category": "formatting",
    "supports": {
        "align": true
    },
    "acf": {
        "mode": "preview",
        "renderTemplate": "testimonial.php",
        "postTypes": ["post", "page"],
        "blockVersion": 2
    }
}
Enter fullscreen mode Exit fullscreen mode

After (functions.php, registration only):

add_action( 'init', 'my_acf_blocks_init' );
function my_acf_blocks_init() {
    register_block_type( __DIR__ . '/blocks/testimonial' );
}
Enter fullscreen mode Exit fullscreen mode

Notice supports.mode and supports.multiple dropped out. Their block.json behavior isn't documented, so I removed them from the matrix example and tested the block manually in the editor to confirm the preview/edit toggle and multiple-instance behavior still worked as expected with ACF's defaults. They did, on ACF 6.3 through 6.5. Verify on your own version before you trust that in production.

A script to catch what you'll miss

Forty blocks is enough that eyeballing each array turns into a chore, and chores produce typos. This script scans a theme for acf_register_block_type() calls, pulls the array keys, and flags anything without a confirmed block.json mapping so you can review it by hand instead of guessing.

<?php
/**
 * Scan a directory for acf_register_block_type() calls and report
 * which array keys have a confirmed block.json equivalent versus
 * which ones need manual review.
 *
 * Usage: php scan-acf-blocks.php /path/to/theme
 */

$confirmed_map = array(
    'name'              => 'name (top-level, must be namespaced)',
    'title'             => 'title (top-level)',
    'description'       => 'description (top-level)',
    'category'          => 'category (top-level)',
    'icon'              => 'icon (top-level)',
    'keywords'          => 'keywords (top-level)',
    'post_types'        => 'acf.postTypes',
    'mode'              => 'acf.mode',
    'render_template'   => 'acf.renderTemplate',
    'render_callback'   => 'acf.renderCallback',
    'enqueue_style'     => 'style (top-level, array of file refs)',
    'enqueue_script'    => 'script (top-level, array of file refs)',
    'example'           => 'example (top-level)',
    'acf_block_version' => 'acf.blockVersion',
);

$no_equivalent = array(
    'enqueue_assets' => 'split across editorScript/editorStyle/script/style',
    'align'          => 'no default-alignment key; use attributes.align.default',
);

$undocumented = array( 'align_text', 'align_content' );

$target_dir = $argv[1] ?? '.';

$files = new RecursiveIteratorIterator(
    new RecursiveDirectoryIterator( $target_dir, FilesystemIterator::SKIP_DOTS )
);

foreach ( $files as $file ) {
    if ( $file->getExtension() !== 'php' ) {
        continue;
    }

    $contents = file_get_contents( $file->getPathname() );

    if ( strpos( $contents, 'acf_register_block_type' ) === false ) {
        continue;
    }

    preg_match_all( "/'([a-z_]+)'\s*=>/", $contents, $matches );
    $keys_found = array_unique( $matches[1] );

    echo "\n--- " . $file->getPathname() . " ---\n";

    foreach ( $keys_found as $key ) {
        if ( isset( $confirmed_map[ $key ] ) ) {
            echo "  OK        {$key} -> {$confirmed_map[$key]}\n";
        } elseif ( isset( $no_equivalent[ $key ] ) ) {
            echo "  NO MATCH  {$key} -> {$no_equivalent[$key]}\n";
        } elseif ( in_array( $key, $undocumented, true ) ) {
            echo "  VERIFY    {$key} -> undocumented for block.json, test manually\n";
        } elseif ( in_array( $key, array( 'align', 'supports' ), true ) ) {
            echo "  REVIEW    {$key} -> partial mapping, check the matrix\n";
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it against a theme directory and it prints a per-file breakdown: keys with a confirmed home, keys with none, and keys that need manual testing. It won't rewrite your blocks. It tells you where to look before you start.

What to check after migrating

Automated key mapping gets you most of the way. Three things still need manual verification, because they depend on the ACF version and WordPress version running in production rather than anything a static matrix can guarantee.

  • Confirm renderTemplate paths resolve. They're relative to block.json's directory now, not the theme root, and a wrong path fails silently with a blank block instead of an error.
  • Confirm asset loading in the site editor and in iframed previews, not just the classic post editor. This is the exact scenario ACF cites as the reason style/script replaced enqueue_style/enqueue_script: scripts and styles could fail to load correctly in block patterns and iframed previews after WordPress 5.8.
  • Confirm any block relying on supports.mode, supports.multiple, or supports.align_text behaves the same after migration. Test the specific version combination you're running. Don't assume the undocumented keys carry over just because they didn't throw an error.

That last point is the one worth repeating to anyone doing this migration at scale. A silent behavior change is worse than a broken block, because a broken block gets caught in QA and a silently degraded one ships.


Chris Mucheke is a backend engineer who has led WooCommerce and ACF migrations, including HPOS work and technical support duties for Advanced Custom Fields. Connect on LinkedIn or follow @kiunye_ on X.

Top comments (0)