Skip to main content

load_field, prepare_field, and Dynamic Choice Population

Dynamic field hooks let editor forms adapt to live business data while remaining deterministic and auditable.

Learning Focus

You will use acf/load_field to populate runtime choices, acf/prepare_field to tailor field visibility and messaging by context, and CLI/source checks to prove your admin UX logic is stable.

Concept Overview

acf/load_field runs before a field is rendered and is ideal for modifying field definitions such as choices, defaults, labels, and instructions. acf/prepare_field runs later and can hide fields, inject context-specific instructions, or alter wrappers based on user capability and screen state.

These hooks are powerful because they shift field configuration from static setup to runtime policy. That makes forms accurate when source data changes frequently (plans, regions, owners) and keeps admin screens cleaner for role-specific workflows.

The tradeoff is operational discipline. Dynamic hooks can slow editor screens or create confusing behavior if they are unscoped, uncached, or undocumented. Production-safe usage means targeted hooks, predictable value keys, and explicit cache invalidation rules.

Core Idea

Use load_field for data-driven field definitions and prepare_field for context-driven presentation, with strict targeting and cache controls.

Why It Matters

ApproachWhat HappensImpact in Production
Populate choices from canonical live sourcesEditors select valid current optionsLower correction work and cleaner analytics
Hardcode dynamic lists in field settingsLists become stale after business changesContent inconsistency and reporting errors
Hide irrelevant fields per role/contextAdmin screens stay focused and faster to useBetter editorial efficiency and fewer mistakes
Cache expensive choice queries with invalidationDynamic UX remains responsiveBetter admin performance under load
Unscoped dynamic hooks (wrong pattern)Unrelated fields mutate unexpectedlyDebugging complexity and trust erosion

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf/load_fieldadd_filter('acf/load_field', callable $callback, int $priority = 10, int $accepted_args = 1): voidModify field settings before renderUse targeted variants for precision
acf/prepare_fieldadd_filter('acf/prepare_field', callable $callback, int $priority = 10, int $accepted_args = 1): voidFinal presentation stage (hide/annotate field)Return false to hide field intentionally
Name-targeted load hookacf/load_field/name=pricing_planDynamic choices for one fieldStable naming policy required
Key-targeted prepare hookacf/prepare_field/key=field_internal_noteRole-specific visibility for one fieldStrongest precision against accidental overlap
Transient cacheget_transient(), set_transient(), delete_transient()Speed up dynamic choice generationAdd invalidation hooks when source changes
Canonical choice keys['starter' => 'Starter']Stable stored values behind human labelsNever change stored keys casually
wp evalwp eval '<php-code>'Test hook registration and payload shapeInclude in release checks
Repeater-driven choicessource rows from Pro fieldsBuild dynamic options from structured configACF Pro Required when source is Repeater/Flexible

Hook Targeting Syntax

Prefer targeted hook variants:

VariantExampleWhen to Use
Globalacf/load_fieldBroad diagnostics only
By nameacf/load_field/name=pricing_planStandard dynamic choice workflows
By keyacf/load_field/key=field_pricing_planImmutable precision in mature projects
By typeacf/load_field/type=selectPolicy defaults for all select fields (use carefully)

Practical Use Cases

Use Case 1 — Populate pricing plan choices from live CPT with cache

Marketing manages plans as a custom post type. Editors should always see current plans in pricing_plan select field without manual field updates.

  1. Query published plan posts with deterministic ordering.
  2. Build associative choices map with stable keys.
  3. Cache generated choices with transient for admin performance.
  4. Invalidate cache on save_post_plan.
  5. Verify generated choices via CLI simulation.
wp-content/themes/clinic-pro/inc/acf/dynamic-plan-choices.php
<?php

declare(strict_types=1);

add_filter('acf/load_field/name=pricing_plan', function (array $field): array {
$cacheKey = 'acf_pricing_plan_choices_v1';
$choices = get_transient($cacheKey);

if (!is_array($choices)) {
$choices = [];

$plans = get_posts([
'post_type' => 'plan',
'post_status' => 'publish',
'numberposts' => -1,
'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'],
'fields' => 'ids',
]);

foreach ($plans as $planId) {
$slug = get_post_field('post_name', $planId);
$title = get_the_title($planId);
if ($slug === '' || $title === '') {
continue;
}
$choices[(string) $slug] = (string) $title;
}

set_transient($cacheKey, $choices, 5 * MINUTE_IN_SECONDS);
}

$field['choices'] = $choices;
$field['instructions'] = 'Choose from live plan catalog (auto-synced).';
return $field;
}, 20);

add_action('save_post_plan', function (int $postId, WP_Post $post, bool $update): void {
delete_transient('acf_pricing_plan_choices_v1');
error_log('[acf-dynamic-plan] cache invalidated for plan=' . $postId);
}, 10, 3);
terminal: command
wp post create --post_type=plan --post_title="Starter" --post_status=publish --post_name=starter
wp post create --post_type=plan --post_title="Growth" --post_status=publish --post_name=growth
wp eval '$field=["name"=>"pricing_plan","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); print_r($field["choices"]);'
terminal: output
Success: Created post 1370.
Success: Created post 1371.
Array
(
[growth] => Growth
[starter] => Starter
)
warning

Do not use post IDs as stored choice keys when business logic expects semantic slugs. IDs are environment-specific and can drift across migrations.

Use Case 2 — Role-aware field visibility with prepare_field + contextual messaging

Compliance notes should be visible only to admins. Editors should see guidance text, not the internal note field itself.

  1. Target internal field by key for precision.
  2. Hide field for users without manage_options capability.
  3. Add guidance note to related public field.
  4. Keep behavior deterministic across post types.
  5. Verify logic with CLI role simulation checks.
wp-content/themes/clinic-pro/inc/acf/role-aware-prepare-field.php
<?php

declare(strict_types=1);

add_filter('acf/prepare_field/key=field_internal_audit_note', function ($field) {
if (!current_user_can('manage_options')) {
return false;
}

$field['instructions'] = trim((string) ($field['instructions'] ?? ''))
. ' Visible to compliance administrators only.';

return $field;
}, 20);

add_filter('acf/prepare_field/name=public_summary', function ($field) {
if (!is_array($field)) {
return $field;
}

if (!current_user_can('manage_options')) {
$field['instructions'] = trim((string) ($field['instructions'] ?? ''))
. ' Need legal review context? Ask a compliance admin.';
}

return $field;
}, 30);

add_action('current_screen', function (WP_Screen $screen): void {
if ($screen->base === 'post' && in_array($screen->post_type, ['page', 'case_study'], true)) {
error_log('[acf-prepare-screen] base=' . $screen->base . ' post_type=' . (string) $screen->post_type);
}
});
terminal: command
wp eval 'echo has_filter("acf/prepare_field/key=field_internal_audit_note") ? "yes" : "no"; echo PHP_EOL;'
wp eval '$field=["name"=>"public_summary","instructions"=>"Initial"]; $field=apply_filters("acf/prepare_field/name=public_summary",$field); echo $field["instructions"] . PHP_EOL;'
terminal: output
yes
Initial Need legal review context? Ask a compliance admin.
note

Use key-targeted hooks for sensitive fields; name-targeted hooks are easier to read but less immutable.

Use Case 3 — Edge case: stale cached choices and unstable keys break existing content

A team caches dynamic choices but changes stored keys from starter to plan_starter. Existing records no longer map cleanly, and editors see "orphaned" selected values.

❌ Fragile Pattern

wp-content/themes/clinic-pro/inc/acf/dynamic-choices-fragile.php
<?php

declare(strict_types=1);

add_filter('acf/load_field/name=pricing_plan', function (array $field): array {
$field['choices'] = get_transient('acf_pricing_plan_choices_v1') ?: [];
return $field;
});

✅ Robust Pattern

wp-content/themes/clinic-pro/inc/acf/dynamic-choices-robust.php
<?php

declare(strict_types=1);

add_filter('acf/load_field/name=pricing_plan', function (array $field): array {
$cacheKey = 'acf_pricing_plan_choices_v2';
$choices = get_transient($cacheKey);

if (!is_array($choices) || count($choices) === 0) {
$choices = [];
$plans = get_posts([
'post_type' => 'plan',
'post_status' => 'publish',
'numberposts' => -1,
'orderby' => ['menu_order' => 'ASC', 'title' => 'ASC'],
'fields' => 'ids',
]);

foreach ($plans as $planId) {
$legacySlug = (string) get_post_field('post_name', $planId);
if ($legacySlug === '') {
continue;
}

// Keep stable legacy keys to preserve existing content mapping.
$choices[$legacySlug] = (string) get_the_title($planId);
}

set_transient($cacheKey, $choices, 5 * MINUTE_IN_SECONDS);
}

$field['choices'] = $choices;

$currentValue = $field['value'] ?? null;
if (is_string($currentValue) && $currentValue !== '' && !array_key_exists($currentValue, $choices)) {
$field['instructions'] = trim((string) ($field['instructions'] ?? ''))
. ' Warning: stored value is not in current choice list (' . esc_html($currentValue) . ').';
}

return $field;
}, 20);

add_action('save_post_plan', function (): void {
delete_transient('acf_pricing_plan_choices_v2');
});
terminal: command
wp eval 'set_transient("acf_pricing_plan_choices_v2", ["starter"=>"Starter","growth"=>"Growth"], 300); $field=["name"=>"pricing_plan","value"=>"starter","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); print_r($field["choices"]);'
wp eval 'set_transient("acf_pricing_plan_choices_v2", ["plan_starter"=>"Starter"], 300); $field=["name"=>"pricing_plan","value"=>"starter","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); echo $field["instructions"] ?? "no-warning"; echo PHP_EOL;'
terminal: output
Array
(
[starter] => Starter
[growth] => Growth
)
Warning: stored value is not in current choice list (starter).
warning

Changing stored choice keys after content is live is a data migration event, not a simple label update.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Dynamic choice queries run on every load without cacheNo performance budgetSlow editor screens and timeoutsCache choice payload and invalidate on source updates
Choice keys changed casuallyKeys treated as labelsExisting saved values become orphanedKeep keys stable; change labels only
Using global acf/prepare_field for sensitive behaviorScope too broadUnrelated fields hidden or mutatedTarget by name= or key=
Returning false without communicationHidden field surprises editorsWorkflow confusion and support loadAdd visible guidance in related field instructions
No CLI verification for dynamic payloadsBehavior checked only visuallyDrift discovered lateAdd wp eval hook-payload checks
Cache invalidation missingStale choices persistEditors see outdated optionsInvalidate transients on source CPT save/delete
Deep Dive: Why Dynamic Choice Bugs Often Look Like Content Bugs

When dynamic choices drift, editors usually notice wrong labels or missing selections, which appears like a content issue. In reality, the root cause is often cache staleness or key changes in field-choice logic. Because stored values are still present in meta, the mismatch is subtle and can survive basic QA. The fastest diagnosis is to inspect current choice keys and compare them to stored values for representative records. This turns a subjective UI issue into an objective data contract check.

wp eval '$field=["name"=>"pricing_plan","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); print_r(array_keys($field["choices"])); echo get_field("pricing_plan",1360) . PHP_EOL;'

Best Practices

  1. Populate dynamic choices from one canonical source (CPT, option config, or API cache).
  2. Treat stored choice keys as immutable IDs once content is live.
  3. Use targeted hook variants (name=/key=) for all production field logic.
  4. Cache dynamic payloads with deterministic invalidation hooks.
  5. Use prepare_field to simplify UX, not to hide critical workflow fields silently.
  6. Log screen/context and hook effects during rollout for observability.
  7. Add CLI checks for dynamic choices and hidden-field policies in release gate.

Hands-On Practice

Exercise 1: Build dynamic pricing choices from plan CPT

Create wp-content/themes/clinic-pro/inc/acf/dynamic-plan-choices.php and run:

wp eval '$field=["name"=>"pricing_plan","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); print_r($field["choices"]);'

After completing this exercise, output should include live plan keys and labels:

starter
growth

Exercise 2: Verify cache invalidation behavior

Run:

wp eval 'set_transient("acf_pricing_plan_choices_v1", ["starter"=>"Starter"], 300); print_r(get_transient("acf_pricing_plan_choices_v1"));'
wp post create --post_type=plan --post_title="Enterprise" --post_status=publish --post_name=enterprise
wp eval 'do_action("save_post_plan",1372,get_post(1372),true); var_export(get_transient("acf_pricing_plan_choices_v1")); echo PHP_EOL;'

After completing this exercise, output should show transient cleared:

false

Exercise 3: Implement role-based field hiding

Create wp-content/themes/clinic-pro/inc/acf/role-aware-prepare-field.php and run:

wp eval 'echo has_filter("acf/prepare_field/key=field_internal_audit_note") ? "yes" : "no"; echo PHP_EOL;'

After completing this exercise, output should be:

yes

Exercise 4: Validate prepare-field instruction override

Run:

wp eval '$field=["name"=>"public_summary","instructions"=>"Initial"]; $result=apply_filters("acf/prepare_field/name=public_summary",$field); echo $result["instructions"] . PHP_EOL;'

After completing this exercise, output should include guidance text:

Initial Need legal review context? Ask a compliance admin.

Exercise 5: Run dynamic field release smoke gate

Run:

wp eval 'echo "load_field=" . (int) has_filter("acf/load_field/name=pricing_plan") . PHP_EOL; echo "prepare_key=" . (int) has_filter("acf/prepare_field/key=field_internal_audit_note") . PHP_EOL; echo "prepare_name=" . (int) has_filter("acf/prepare_field/name=public_summary") . PHP_EOL;'

After completing this exercise, output pattern should be:

load_field=1
prepare_key=1
prepare_name=1

CLI Reference

CommandPurposeReal Example Output
wp eval '$field=["name"=>"pricing_plan","choices"=>[]]; $field=apply_filters("acf/load_field/name=pricing_plan",$field); print_r($field["choices"]);'Inspect dynamic select choicesArray ( [starter] => Starter ... )
wp eval 'echo has_filter("acf/load_field/name=pricing_plan") ? "yes" : "no"; echo PHP_EOL;'Verify targeted load hook registrationyes
wp eval 'echo has_filter("acf/prepare_field/key=field_internal_audit_note") ? "yes" : "no"; echo PHP_EOL;'Verify sensitive field prepare hook wiringyes
wp eval 'set_transient("acf_pricing_plan_choices_v1", ["starter"=>"Starter"], 300); print_r(get_transient("acf_pricing_plan_choices_v1"));'Validate transient cache payloadArray ( [starter] => Starter )
wp eval 'do_action("save_post_plan",1372,get_post(1372),true); var_export(get_transient("acf_pricing_plan_choices_v1")); echo PHP_EOL;'Test cache invalidation on source updatefalse
wp post create --post_type=plan --post_title="Enterprise" --post_status=publish --post_name=enterpriseAdd live source record for dynamic choicesSuccess: Created post 1372.
wp eval '$field=["name"=>"public_summary","instructions"=>"Initial"]; $field=apply_filters("acf/prepare_field/name=public_summary",$field); echo $field["instructions"] . PHP_EOL;'Test contextual instruction mutationInitial Need legal review context?...
`wp plugin list --status=activegrep advanced-custom-fields-pro`Confirm ACF Pro dependency state

What's Next

tip

Revisit this lesson when business-owned option lists (plans, regions, service tiers) change often; dynamic choice hygiene prevents silent editorial drift.