load_field, prepare_field, and Dynamic Choice Population
Dynamic field hooks let editor forms adapt to live business data while remaining deterministic and auditable.
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.
Use load_field for data-driven field definitions and prepare_field for context-driven presentation, with strict targeting and cache controls.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Populate choices from canonical live sources | Editors select valid current options | Lower correction work and cleaner analytics |
| Hardcode dynamic lists in field settings | Lists become stale after business changes | Content inconsistency and reporting errors |
| Hide irrelevant fields per role/context | Admin screens stay focused and faster to use | Better editorial efficiency and fewer mistakes |
| Cache expensive choice queries with invalidation | Dynamic UX remains responsive | Better admin performance under load |
| Unscoped dynamic hooks (wrong pattern) | Unrelated fields mutate unexpectedly | Debugging complexity and trust erosion |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/load_field | add_filter('acf/load_field', callable $callback, int $priority = 10, int $accepted_args = 1): void | Modify field settings before render | Use targeted variants for precision |
acf/prepare_field | add_filter('acf/prepare_field', callable $callback, int $priority = 10, int $accepted_args = 1): void | Final presentation stage (hide/annotate field) | Return false to hide field intentionally |
| Name-targeted load hook | acf/load_field/name=pricing_plan | Dynamic choices for one field | Stable naming policy required |
| Key-targeted prepare hook | acf/prepare_field/key=field_internal_note | Role-specific visibility for one field | Strongest precision against accidental overlap |
| Transient cache | get_transient(), set_transient(), delete_transient() | Speed up dynamic choice generation | Add invalidation hooks when source changes |
| Canonical choice keys | ['starter' => 'Starter'] | Stable stored values behind human labels | Never change stored keys casually |
wp eval | wp eval '<php-code>' | Test hook registration and payload shape | Include in release checks |
| Repeater-driven choices | source rows from Pro fields | Build dynamic options from structured config | ACF Pro Required when source is Repeater/Flexible |
Hook Targeting Syntax
Prefer targeted hook variants:
| Variant | Example | When to Use |
|---|---|---|
| Global | acf/load_field | Broad diagnostics only |
| By name | acf/load_field/name=pricing_plan | Standard dynamic choice workflows |
| By key | acf/load_field/key=field_pricing_plan | Immutable precision in mature projects |
| By type | acf/load_field/type=select | Policy 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.
- Query published
planposts with deterministic ordering. - Build associative
choicesmap with stable keys. - Cache generated choices with transient for admin performance.
- Invalidate cache on
save_post_plan. - Verify generated choices via CLI simulation.
<?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);
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"]);'
Success: Created post 1370.
Success: Created post 1371.
Array
(
[growth] => Growth
[starter] => Starter
)
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.
- Target internal field by key for precision.
- Hide field for users without
manage_optionscapability. - Add guidance note to related public field.
- Keep behavior deterministic across post types.
- Verify logic with CLI role simulation checks.
<?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);
}
});
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;'
yes
Initial Need legal review context? Ask a compliance admin.
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
<?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
<?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');
});
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;'
Array
(
[starter] => Starter
[growth] => Growth
)
Warning: stored value is not in current choice list (starter).
Changing stored choice keys after content is live is a data migration event, not a simple label update.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Dynamic choice queries run on every load without cache | No performance budget | Slow editor screens and timeouts | Cache choice payload and invalidate on source updates |
| Choice keys changed casually | Keys treated as labels | Existing saved values become orphaned | Keep keys stable; change labels only |
Using global acf/prepare_field for sensitive behavior | Scope too broad | Unrelated fields hidden or mutated | Target by name= or key= |
Returning false without communication | Hidden field surprises editors | Workflow confusion and support load | Add visible guidance in related field instructions |
| No CLI verification for dynamic payloads | Behavior checked only visually | Drift discovered late | Add wp eval hook-payload checks |
| Cache invalidation missing | Stale choices persist | Editors see outdated options | Invalidate 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
- Populate dynamic choices from one canonical source (CPT, option config, or API cache).
- Treat stored choice keys as immutable IDs once content is live.
- Use targeted hook variants (
name=/key=) for all production field logic. - Cache dynamic payloads with deterministic invalidation hooks.
- Use
prepare_fieldto simplify UX, not to hide critical workflow fields silently. - Log screen/context and hook effects during rollout for observability.
- 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
| Command | Purpose | Real 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 choices | Array ( [starter] => Starter ... ) |
wp eval 'echo has_filter("acf/load_field/name=pricing_plan") ? "yes" : "no"; echo PHP_EOL;' | Verify targeted load hook registration | yes |
wp eval 'echo has_filter("acf/prepare_field/key=field_internal_audit_note") ? "yes" : "no"; echo PHP_EOL;' | Verify sensitive field prepare hook wiring | yes |
wp eval 'set_transient("acf_pricing_plan_choices_v1", ["starter"=>"Starter"], 300); print_r(get_transient("acf_pricing_plan_choices_v1"));' | Validate transient cache payload | Array ( [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 update | false |
wp post create --post_type=plan --post_title="Enterprise" --post_status=publish --post_name=enterprise | Add live source record for dynamic choices | Success: 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 mutation | Initial Need legal review context?... |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro dependency state |
What's Next
- Continue to validate_value, Sanitization, and Publish Guards.
- Return to Module 7 Overview for full admin UX/validation context.
- Related lesson: Field Retrieval APIs and Context-Aware Data Access.
- Related lesson: Exposing ACF in REST API Securely.
Revisit this lesson when business-owned option lists (plans, regions, service tiers) change often; dynamic choice hygiene prevents silent editorial drift.