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',
));
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"
}
}
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,
),
) );
}
}
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
}
}
After (functions.php, registration only):
add_action( 'init', 'my_acf_blocks_init' );
function my_acf_blocks_init() {
register_block_type( __DIR__ . '/blocks/testimonial' );
}
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";
}
}
}
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
renderTemplatepaths 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/scriptreplacedenqueue_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, orsupports.align_textbehaves 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)