ACF Blocks Core Concepts
ACF blocks are most reliable when block registration, field schema ownership, and rendering behavior are all defined in code.
You will understand ACF Pro block lifecycle from registration to frontend output, implement robust preview behavior, and validate block bootstrapping through code and CLI inspection commands.
Concept Overview
An ACF block has three layers: metadata (name, category, icon, supports), schema (field group and location for the block), and rendering (template or callback). These layers must evolve together. If one drifts, block behavior becomes inconsistent across editor preview and frontend output.
In production teams, block failures are often not obvious syntax errors. They are integration errors: block registered but fields missing, preview branch not handled, or render callback assuming data exists. A code-first workflow adds guardrails for each layer and verifies key hooks/paths before release.
ACF Pro block APIs provide flexibility (acf_register_block_type, acf/blocks/no_fields_assigned_message, preview flags), but flexibility without policy produces support overhead. You need deterministic conventions for naming, registration location, fallback messaging, and CLI smoke checks.
Treat each ACF block as a mini-component system: register safely, validate field assignment, and render with explicit preview/empty-state branches.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
Register blocks on acf/init with API guard | Missing dependency states fail safely | Fewer fatal incidents during plugin/config drift |
| Keep render callback with explicit preview handling | Editors see meaningful placeholders instead of blanks | Better editorial confidence and fewer support requests |
| Tie each block to a tracked field group | Block data contract is auditable | More predictable releases and easier debugging |
| Add fallback messages when fields are unassigned | Misconfiguration is visible immediately | Faster correction during setup and migrations |
| Skip block guardrails (wrong pattern) | Blocks appear but render empty/broken | Rework, content team confusion, and unstable launches |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf_register_block_type() | acf_register_block_type(array $settings): void | Register custom ACF block from PHP | ACF Pro Required |
acf/init | add_action('acf/init', callable $callback): void | Safe registration timing for ACF APIs | Always guard with function_exists |
| Render callback | `function callback(array $block, string $content = '', bool $is_preview = false, int | string $post_id = 0): void` | Render preview/frontend markup |
acf/blocks/no_fields_assigned_message | add_filter('acf/blocks/no_fields_assigned_message', callable $callback, int $priority, int $accepted_args): void | Customize missing-field message | Useful for migration and setup diagnostics |
| Block location rule | 'param' => 'block', 'operator' => '==', 'value' => 'acf/testimonial-card' | Attach field groups to block context | Keep block name stable |
register_block_type() | `register_block_type(string $path, array $args = []): WP_Block_Type | false` | Register block via metadata path |
has_block() | `has_block(string $block_name, WP_Post | string | null $post = null): bool` |
wp eval | wp eval '<php-code>' | Validate registration hooks and APIs | CLI cannot fully render editor UI but can verify runtime pieces |
ACF custom block registration and related block filters in this lesson require ACF Pro.
block.json Schema
Use this metadata structure when you transition from callback-only registration to metadata-driven registration:
{
"name": "clinic-pro/testimonial-card",
"title": "Testimonial Card",
"description": "Reusable testimonial block with quote, author, and role",
"category": "widgets",
"icon": "format-quote",
"keywords": ["testimonial", "quote", "review"],
"supports": {
"align": ["wide", "full"],
"anchor": true,
"jsx": true
},
"acf": {
"mode": "preview",
"renderTemplate": "blocks/testimonial-card/render.php"
}
}
<?php
declare(strict_types=1);
add_action('init', function (): void {
$path = get_stylesheet_directory() . '/blocks/testimonial-card';
if (is_dir($path)) {
register_block_type($path);
}
});
Practical Use Cases
Use Case 1 — Register a testimonial ACF block with preview-safe render callback
A marketing team needs a reusable testimonial component in multiple pages. The block must render correctly on frontend and provide clear preview behavior when fields are still empty.
- Register block on
acf/initwith API guard. - Define callback signature including
$is_previewand$post_id. - Retrieve quote/name/role fields explicitly.
- Render preview placeholder when content is incomplete.
- Verify registration and API availability through CLI.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
if (!function_exists('acf_register_block_type')) {
error_log('[acf-block] registration skipped: acf_register_block_type unavailable');
return;
}
acf_register_block_type([
'name' => 'testimonial-card',
'title' => 'Testimonial Card',
'description' => 'Display one testimonial quote with attribution',
'category' => 'widgets',
'icon' => 'format-quote',
'keywords' => ['testimonial', 'quote', 'social proof'],
'mode' => 'preview',
'render_callback' => 'clinic_render_testimonial_card',
'supports' => [
'align' => ['wide', 'full'],
'anchor' => true,
],
]);
});
function clinic_render_testimonial_card(array $block, string $content = '', bool $is_preview = false, $post_id = 0): void
{
$quote = (string) get_field('testimonial_quote');
$author = (string) get_field('testimonial_author');
$role = (string) get_field('testimonial_role');
if ($is_preview && ($quote === '' || $author === '')) {
echo '<div class="acf-block-preview">';
echo '<strong>Testimonial Card Preview</strong>';
echo '<p>Add quote and author fields to preview final output.</p>';
echo '</div>';
return;
}
if ($quote === '' || $author === '') {
return;
}
echo '<blockquote class="testimonial-card">';
echo '<p class="testimonial-quote">' . esc_html($quote) . '</p>';
echo '<cite class="testimonial-author">' . esc_html($author);
if ($role !== '') {
echo ' - ' . esc_html($role);
}
echo '</cite>';
echo '</blockquote>';
}
wp eval 'var_export(function_exists("acf_register_block_type")); echo PHP_EOL;'
wp eval 'echo has_action("acf/init") ? "acf-init-hooked" : "acf-init-missing"; echo PHP_EOL;'
wp eval 'var_export(function_exists("clinic_render_testimonial_card")); echo PHP_EOL;'
true
acf-init-hooked
true
Never assume block fields are present in preview mode. $is_preview is where most user-facing editor confusion can be prevented.
Use Case 2 — Add block field-assignment diagnostics and operational fallback messaging
During migration, some blocks are registered before field groups are synced. Editors see blank blocks without context. You add a custom no-fields-assigned message and logging trail.
- Register fallback filter for missing block fields.
- Include block name in log message.
- Return actionable message to editors.
- Verify filter wiring in CLI.
- Add release check for field assignment drift.
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
if (!function_exists('acf_register_block_type')) {
return;
}
error_log('[acf-block-diagnostics] init-ready');
});
add_filter('acf/blocks/no_fields_assigned_message', function ($message, $block) {
$blockName = is_array($block) ? (string) ($block['name'] ?? 'unknown-block') : 'unknown-block';
error_log('[acf-block-diagnostics] no-fields-assigned block=' . $blockName);
return 'Fields are not assigned to this block yet. Sync Local JSON or verify field group location rule for block "'
. esc_html($blockName)
. '".';
}, 10, 2);
add_filter('acf/prepare_field', function ($field) {
if (!is_array($field)) {
return $field;
}
if (!empty($field['parent']) && str_starts_with((string) $field['parent'], 'group_')) {
$field['wrapper']['class'] = trim(((string) ($field['wrapper']['class'] ?? '')) . ' acf-block-field');
}
return $field;
}, 10);
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "yes" : "no"; echo PHP_EOL;'
wp eval 'echo has_filter("acf/prepare_field") ? "yes" : "no"; echo PHP_EOL;'
wp eval 'echo has_action("acf/init") ? "yes" : "no"; echo PHP_EOL;'
yes
yes
yes
This does not replace proper schema sync; it reduces time-to-diagnosis when sync is missing.
Use Case 3 — Edge case: block registered but render callback assumes missing fields are always populated
A block is visible in inserter, but preview and frontend are blank because callback renders without validating required fields. You compare fragile and robust callback patterns.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
acf_register_block_type([
'name' => 'hero-banner',
'title' => 'Hero Banner',
'render_callback' => 'clinic_render_hero_fragile',
]);
});
function clinic_render_hero_fragile(array $block): void
{
echo '<section class="hero"><h2>' . esc_html((string) get_field('hero_title')) . '</h2>';
echo '<a href="' . esc_url((string) get_field('hero_cta_url')) . '">' . esc_html((string) get_field('hero_cta_label')) . '</a></section>';
}
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('acf/init', function (): void {
if (!function_exists('acf_register_block_type')) {
return;
}
acf_register_block_type([
'name' => 'hero-banner',
'title' => 'Hero Banner',
'description' => 'Hero section with safe fallback strategy',
'category' => 'layout',
'icon' => 'cover-image',
'mode' => 'preview',
'render_callback' => 'clinic_render_hero_robust',
]);
});
function clinic_render_hero_robust(array $block, string $content = '', bool $is_preview = false): void
{
$title = (string) get_field('hero_title');
$ctaLabel = (string) get_field('hero_cta_label');
$ctaUrl = (string) get_field('hero_cta_url');
if ($title === '') {
$title = 'Default hero headline';
}
if ($is_preview && ($ctaLabel === '' || $ctaUrl === '')) {
echo '<div class="acf-block-preview">';
echo '<h3>' . esc_html($title) . '</h3>';
echo '<p>Set CTA label and URL for final output.</p>';
echo '</div>';
return;
}
echo '<section class="hero-banner">';
echo '<h2>' . esc_html($title) . '</h2>';
if ($ctaLabel !== '' && $ctaUrl !== '') {
echo '<a class="hero-banner-cta" href="' . esc_url($ctaUrl) . '">' . esc_html($ctaLabel) . '</a>';
}
echo '</section>';
}
wp eval 'var_export(function_exists("clinic_render_hero_fragile")); echo PHP_EOL; var_export(function_exists("clinic_render_hero_robust")); echo PHP_EOL;'
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "fallback_filter_ready" : "fallback_filter_missing"; echo PHP_EOL;'
true
true
fallback_filter_ready
A block can be technically registered yet practically unusable if callback fallback policy is absent.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Registering block without API guard | Assumes ACF Pro always active | Fatal risk or missing block registration in edge environments | Wrap registration in function_exists('acf_register_block_type') guard |
Missing $is_preview logic | Preview branch not considered | Editors see blank or misleading block output | Add explicit preview placeholder branch |
| Field group not scoped to block location | Schema-location mismatch | Block appears without editable fields | Use block location rule and verify via schema audit |
| No diagnostics for missing fields | Silent misconfiguration | Slow troubleshooting during setup/migration | Use acf/blocks/no_fields_assigned_message filter + logs |
| Inline rendering without contract checks | Required fields assumed non-empty | Broken CTA/markup in frontend | Validate required fields and fallback/hide intentionally |
| No CLI smoke checks for block runtime | Team relies only on visual checks | Registration issues found late | Add wp eval function/hook checks in release checklist |
Deep Dive: Why Block Preview Bugs Are Harder to Debug Than They Look
Preview bugs often appear non-deterministic because editor state, unsaved field values, and backend render context differ from frontend requests. A callback that works on published pages can still fail in preview when required values are missing. Teams may misdiagnose this as Gutenberg instability when the root issue is missing preview guards. Logging preview-specific branches and validating required fields before render dramatically reduces this confusion.
wp eval 'echo "acf_register_block_type_exists=" . (int) function_exists("acf_register_block_type") . PHP_EOL; echo "no_fields_filter=" . (int) has_filter("acf/blocks/no_fields_assigned_message") . PHP_EOL;'
Best Practices
- Register blocks only on
acf/initwith explicit ACF API guards. - Use stable namespaced block identifiers and keep them immutable once used in content.
- Implement
$is_previewfallback output for every custom block callback. - Treat missing field assignment as an operational event, not a silent UI issue.
- Keep one registration file per block or per block domain for auditability.
- Add CLI smoke checks for API existence, hook wiring, and callback availability.
- Synchronize block metadata and field-group location rules in the same PR.
Hands-On Practice
Exercise 1: Register one ACF block with callback guard
Create wp-content/themes/clinic-pro/inc/acf/blocks/testimonial-card.php and run:
wp eval 'var_export(function_exists("acf_register_block_type")); echo PHP_EOL;'
After completing this exercise, output should be:
true
Exercise 2: Verify callback function availability
Run:
wp eval 'var_export(function_exists("clinic_render_testimonial_card")); echo PHP_EOL;'
After completing this exercise, output should be:
true
Exercise 3: Add missing-fields diagnostic filter
Create diagnostics file from Use Case 2 and run:
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "yes" : "no"; echo PHP_EOL;'
After completing this exercise, output should be:
yes
Exercise 4: Add metadata block.json and register via path
Create the block metadata files and run:
python3 - <<'PY2'
import json
from pathlib import Path
f = Path('wp-content/themes/clinic-pro/blocks/testimonial-card/block.json')
data = json.loads(f.read_text())
print(data['name'])
print(data['acf']['renderTemplate'])
PY2
After completing this exercise, output should be:
clinic-pro/testimonial-card
blocks/testimonial-card/render.php
Exercise 5: Build block release smoke checks
Run:
wp eval 'echo "api=" . (int) function_exists("acf_register_block_type") . PHP_EOL; echo "hook=" . (int) has_action("acf/init") . PHP_EOL; echo "filter=" . (int) has_filter("acf/blocks/no_fields_assigned_message") . PHP_EOL;'
After completing this exercise, output pattern should be:
api=1
hook=1
filter=1
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval 'var_export(function_exists("acf_register_block_type")); echo PHP_EOL;' | Verify ACF block API availability | true |
wp eval 'echo has_action("acf/init") ? "yes" : "no"; echo PHP_EOL;' | Verify registration hook is loaded | yes |
wp eval 'var_export(function_exists("clinic_render_testimonial_card")); echo PHP_EOL;' | Confirm render callback function exists | true |
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "yes" : "no"; echo PHP_EOL;' | Verify diagnostics filter wiring | yes |
python3 - <<'PY2' ... PY2 | Validate block.json structure and keys | clinic-pro/testimonial-card |
ls -1 wp-content/themes/clinic-pro/blocks/testimonial-card/render.php | Confirm render template path exists | wp-content/themes/clinic-pro/blocks/testimonial-card/render.php |
wp eval 'var_export(has_block("clinic-pro/testimonial-card", get_post(1360))); echo PHP_EOL;' | Check whether a post currently contains the block | true or false |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro dependency status |
What's Next
- Continue to Registering ACF Blocks with block.json.
- Return to Module 4 Overview for full block workflow context.
- Related lesson: InnerBlocks, Editor Guardrails, and Preview Quality.
- Related lesson: Local JSON and Version Control Workflow.
Revisit this lesson whenever a block appears in inserter but renders unexpectedly; the root cause is usually lifecycle mismatch between registration, field assignment, and callback guard logic.