Skip to main content

ACF Blocks Core Concepts

ACF blocks are most reliable when block registration, field schema ownership, and rendering behavior are all defined in code.

Learning Focus

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.

Core Idea

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

ApproachWhat HappensImpact in Production
Register blocks on acf/init with API guardMissing dependency states fail safelyFewer fatal incidents during plugin/config drift
Keep render callback with explicit preview handlingEditors see meaningful placeholders instead of blanksBetter editorial confidence and fewer support requests
Tie each block to a tracked field groupBlock data contract is auditableMore predictable releases and easier debugging
Add fallback messages when fields are unassignedMisconfiguration is visible immediatelyFaster correction during setup and migrations
Skip block guardrails (wrong pattern)Blocks appear but render empty/brokenRework, content team confusion, and unstable launches

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf_register_block_type()acf_register_block_type(array $settings): voidRegister custom ACF block from PHPACF Pro Required
acf/initadd_action('acf/init', callable $callback): voidSafe registration timing for ACF APIsAlways guard with function_exists
Render callback`function callback(array $block, string $content = '', bool $is_preview = false, intstring $post_id = 0): void`Render preview/frontend markup
acf/blocks/no_fields_assigned_messageadd_filter('acf/blocks/no_fields_assigned_message', callable $callback, int $priority, int $accepted_args): voidCustomize missing-field messageUseful for migration and setup diagnostics
Block location rule'param' => 'block', 'operator' => '==', 'value' => 'acf/testimonial-card'Attach field groups to block contextKeep block name stable
register_block_type()`register_block_type(string $path, array $args = []): WP_Block_Typefalse`Register block via metadata path
has_block()`has_block(string $block_name, WP_Poststringnull $post = null): bool`
wp evalwp eval '<php-code>'Validate registration hooks and APIsCLI cannot fully render editor UI but can verify runtime pieces
ACF Pro Required

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:

wp-content/themes/clinic-pro/blocks/testimonial-card/block.json
{
"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"
}
}
wp-content/themes/clinic-pro/inc/acf/register-blocks-metadata.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.

  1. Register block on acf/init with API guard.
  2. Define callback signature including $is_preview and $post_id.
  3. Retrieve quote/name/role fields explicitly.
  4. Render preview placeholder when content is incomplete.
  5. Verify registration and API availability through CLI.
wp-content/themes/clinic-pro/inc/acf/blocks/testimonial-card.php
<?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>';
}
terminal: command
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;'
terminal: output
true
acf-init-hooked
true
warning

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.

  1. Register fallback filter for missing block fields.
  2. Include block name in log message.
  3. Return actionable message to editors.
  4. Verify filter wiring in CLI.
  5. Add release check for field assignment drift.
wp-content/themes/clinic-pro/inc/acf/blocks/block-diagnostics.php
<?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);
terminal: command
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;'
terminal: output
yes
yes
yes
note

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

wp-content/themes/clinic-pro/inc/acf/blocks/hero-fragile.php
<?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

wp-content/themes/clinic-pro/inc/acf/blocks/hero-robust.php
<?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>';
}
terminal: command
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;'
terminal: output
true
true
fallback_filter_ready
warning

A block can be technically registered yet practically unusable if callback fallback policy is absent.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Registering block without API guardAssumes ACF Pro always activeFatal risk or missing block registration in edge environmentsWrap registration in function_exists('acf_register_block_type') guard
Missing $is_preview logicPreview branch not consideredEditors see blank or misleading block outputAdd explicit preview placeholder branch
Field group not scoped to block locationSchema-location mismatchBlock appears without editable fieldsUse block location rule and verify via schema audit
No diagnostics for missing fieldsSilent misconfigurationSlow troubleshooting during setup/migrationUse acf/blocks/no_fields_assigned_message filter + logs
Inline rendering without contract checksRequired fields assumed non-emptyBroken CTA/markup in frontendValidate required fields and fallback/hide intentionally
No CLI smoke checks for block runtimeTeam relies only on visual checksRegistration issues found lateAdd 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

  1. Register blocks only on acf/init with explicit ACF API guards.
  2. Use stable namespaced block identifiers and keep them immutable once used in content.
  3. Implement $is_preview fallback output for every custom block callback.
  4. Treat missing field assignment as an operational event, not a silent UI issue.
  5. Keep one registration file per block or per block domain for auditability.
  6. Add CLI smoke checks for API existence, hook wiring, and callback availability.
  7. 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

CommandPurposeReal Example Output
wp eval 'var_export(function_exists("acf_register_block_type")); echo PHP_EOL;'Verify ACF block API availabilitytrue
wp eval 'echo has_action("acf/init") ? "yes" : "no"; echo PHP_EOL;'Verify registration hook is loadedyes
wp eval 'var_export(function_exists("clinic_render_testimonial_card")); echo PHP_EOL;'Confirm render callback function existstrue
wp eval 'echo has_filter("acf/blocks/no_fields_assigned_message") ? "yes" : "no"; echo PHP_EOL;'Verify diagnostics filter wiringyes
python3 - <<'PY2' ... PY2Validate block.json structure and keysclinic-pro/testimonial-card
ls -1 wp-content/themes/clinic-pro/blocks/testimonial-card/render.phpConfirm render template path existswp-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 blocktrue or false
`wp plugin list --status=activegrep advanced-custom-fields-pro`Confirm ACF Pro dependency status

What's Next

tip

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.