Understanding ACF Hook Lifecycle and Priorities
ACF hook timing determines whether your customization is deterministic or intermittently broken.
You will map ACF hook stages to specific responsibilities, assign deliberate callback priorities, and verify actual runtime registration/execution behavior with WP-CLI. This matters because most advanced ACF bugs come from correct logic attached to the wrong stage.
Concept Overview
ACF hook lifecycle is a flow, not a bag of interchangeable extension points. Each hook exists for a specific phase: initializing APIs, loading field settings, loading values, validating incoming data, updating stored values, post-save side effects, and output formatting. When logic is placed in the wrong phase, behavior can look random because assumptions about data availability are violated.
Priority is the second dimension. Two callbacks on the same hook can produce opposite outcomes depending on execution order. If you rely on default priorities everywhere, integration with plugins and theme modules becomes fragile. Explicit priority strategy is required for stable behavior in team environments.
A practical lifecycle model separates concerns: schema registration on acf/init, dynamic field definition on acf/load_field, validation on acf/validate_value, normalization on acf/update_value, synchronization side effects on acf/save_post, and output-only transforms on acf/format_value. This separation keeps bugs local and easier to debug.
Attach each rule to the earliest correct lifecycle stage and use explicit priority values whenever more than one callback touches the same hook target.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
Use stage-specific hooks (load_field, validate_value, update_value, save_post) | Logic executes with correct data availability assumptions | Lower regression risk and faster troubleshooting |
Put all behavior in acf/save_post | Validation, mutation, and side effects become tangled | High defect rate and difficult incident recovery |
| Declare explicit priorities where callbacks interact | Outcome ordering is deterministic | Better plugin/theme interoperability |
Scope hooks by name= or key= targets | Only intended fields are modified | Fewer accidental cross-field side effects |
| Use global unscoped hooks without strategy (wrong pattern) | Unrelated fields and forms get mutated unexpectedly | Hard-to-reproduce bugs and editor mistrust |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/init | add_action('acf/init', callable $callback, int $priority = 10): void | Register fields, options pages, block bootstraps | ACF APIs are available here |
acf/load_field | add_filter('acf/load_field', callable $callback, int $priority = 10, int $accepted_args = 1): void | Alter field config before render | Prefer targeted variants by name/key/type |
acf/load_value | add_filter('acf/load_value', callable $callback, int $priority = 10, int $accepted_args = 3): void | Transform value before editor or API consumption | Do not use for persistent writes |
acf/validate_value | add_filter('acf/validate_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Block invalid saves with explicit error message | Return true or string message |
acf/update_value | add_filter('acf/update_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Normalize value just before storage | Keep idempotent and side-effect free |
acf/save_post | add_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): void | Perform post-save sync and side effects | Guard against recursion when updating fields |
acf/format_value | add_filter('acf/format_value', callable $callback, int $priority = 10, int $accepted_args = 3): void | Presentation-time formatting | Never mutate stored canonical value here |
wp eval | wp eval '<php-code>' | Verify hook registration and callback existence | Useful release smoke check |
Hook Targeting Syntax
Use targeted hook variants whenever possible:
| Variant | Example | When to Use |
|---|---|---|
| Global | acf/load_field | Very broad instrumentation or diagnostics only |
| By name | acf/load_field/name=service_tier | Stable field names under team governance |
| By key | acf/load_field/key=field_service_tier | Maximum precision when key is immutable |
| By type | acf/load_field/type=select | Cross-project policy behavior by field type |
Practical Use Cases
Use Case 1 — Resolve callback ordering conflict with explicit priorities
A plugin defines dynamic choices for service_tier, but theme customizations also modify the same field. Without explicit priority, final choice list is inconsistent between environments.
- Register baseline choices at lower priority.
- Register theme refinement callback at higher priority.
- Scope both callbacks to the same field target.
- Log final choices count for runtime verification.
- Verify hook presence/order assumptions via CLI.
<?php
declare(strict_types=1);
add_filter('acf/load_field/name=service_tier', 'clinic_plugin_service_tier_choices', 10, 1);
add_filter('acf/load_field/name=service_tier', 'clinic_theme_service_tier_refine_choices', 20, 1);
add_filter('acf/load_field/name=service_tier', 'clinic_theme_service_tier_sort_choices', 30, 1);
function clinic_plugin_service_tier_choices(array $field): array
{
$field['choices'] = [
'starter' => 'Starter',
'growth' => 'Growth',
'premium' => 'Premium',
];
return $field;
}
function clinic_theme_service_tier_refine_choices(array $field): array
{
if (!isset($field['choices']) || !is_array($field['choices'])) {
$field['choices'] = [];
}
$field['choices']['enterprise'] = 'Enterprise';
unset($field['choices']['premium']);
return $field;
}
function clinic_theme_service_tier_sort_choices(array $field): array
{
if (isset($field['choices']) && is_array($field['choices'])) {
asort($field['choices']);
}
error_log('[acf-priority] service_tier_choices=' . wp_json_encode(array_keys($field['choices'])));
return $field;
}
wp eval 'echo has_filter("acf/load_field/name=service_tier") ? "hooked" : "missing"; echo PHP_EOL;'
wp eval '$field=["name"=>"service_tier","choices"=>[]]; $field=apply_filters("acf/load_field/name=service_tier",$field); print_r($field["choices"]);'
hooked
Array
(
[enterprise] => Enterprise
[growth] => Growth
[starter] => Starter
)
If multiple modules touch one hook target, document priority ownership in code comments and architecture docs.
Use Case 2 — Split validation, normalization, and side effects across correct hooks
A lead intake form stores lead_email and lead_phone. You need strict validation, storage normalization, and post-save syncing to an audit option. These should not all live in one callback.
- Validate email/phone format in
acf/validate_value. - Normalize phone in
acf/update_valuebefore storage. - Record post-save audit event in
acf/save_post. - Keep each callback single-purpose.
- Verify behavior with WP-CLI filter/application checks.
<?php
declare(strict_types=1);
add_filter('acf/validate_value/name=lead_email', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$email = (string) $value;
if ($email === '' || !is_email($email)) {
return 'Lead email must be a valid email address.';
}
return true;
}, 10, 4);
add_filter('acf/validate_value/name=lead_phone', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$digits = preg_replace('/\D+/', '', (string) $value);
if (strlen((string) $digits) < 8) {
return 'Lead phone must include at least 8 digits.';
}
return true;
}, 10, 4);
add_filter('acf/update_value/name=lead_phone', function ($value, $post_id, $field, $original) {
$digits = preg_replace('/\D+/', '', (string) $value);
if (!str_starts_with((string) $digits, '65')) {
$digits = '65' . $digits;
}
return '+' . $digits;
}, 10, 4);
add_action('acf/save_post', function ($post_id): void {
if (!is_numeric($post_id)) {
return;
}
$audit = get_option('acf_lifecycle_audit', []);
if (!is_array($audit)) {
$audit = [];
}
$audit[] = [
'post_id' => (int) $post_id,
'saved_at' => gmdate('c'),
'lead_email' => (string) get_field('lead_email', (int) $post_id),
'lead_phone' => (string) get_field('lead_phone', (int) $post_id),
];
update_option('acf_lifecycle_audit', array_slice($audit, -50), false);
}, 30);
wp eval '$v=apply_filters("acf/validate_value/name=lead_email",true,"sales@example.com",["name"=>"lead_email"],"acf[field_x]"); var_export($v); echo PHP_EOL;'
wp eval '$v=apply_filters("acf/update_value/name=lead_phone","(65) 8123-9999",1360,["name"=>"lead_phone"],"(65) 8123-9999"); var_export($v); echo PHP_EOL;'
wp eval 'do_action("acf/save_post",1360); $audit=get_option("acf_lifecycle_audit",[]); echo count($audit) . PHP_EOL;'
true
'+6581239999'
1
This split pattern avoids the common anti-pattern where acf/save_post becomes an untestable mega-callback.
Use Case 3 — Edge case: recursive acf/save_post side effects cause repeated writes
A callback updates fields during acf/save_post, which triggers additional save hooks and duplicates audit records. You compare fragile and robust anti-recursion patterns.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('acf/save_post', function ($post_id): void {
if (!is_numeric($post_id)) {
return;
}
$count = (int) get_field('save_counter', (int) $post_id);
update_field('save_counter', $count + 1, (int) $post_id);
error_log('[acf-save-fragile] updated save_counter for post=' . $post_id);
}, 10);
✅ Robust Pattern
<?php
declare(strict_types=1);
add_action('acf/save_post', 'clinic_safe_save_counter_update', 20, 1);
function clinic_safe_save_counter_update($post_id): void
{
static $inProgress = false;
if ($inProgress || !is_numeric($post_id)) {
return;
}
$inProgress = true;
$postId = (int) $post_id;
$count = (int) get_field('save_counter', $postId);
$newCount = $count + 1;
remove_action('acf/save_post', 'clinic_safe_save_counter_update', 20);
update_field('save_counter', $newCount, $postId);
add_action('acf/save_post', 'clinic_safe_save_counter_update', 20, 1);
$history = get_option('acf_save_counter_audit', []);
if (!is_array($history)) {
$history = [];
}
$history[] = [
'post_id' => $postId,
'counter' => $newCount,
'saved_at' => gmdate('c'),
];
update_option('acf_save_counter_audit', array_slice($history, -100), false);
$inProgress = false;
}
wp eval 'do_action("acf/save_post",1360); do_action("acf/save_post",1360); echo (int)get_field("save_counter",1360) . PHP_EOL;'
wp eval '$audit=get_option("acf_save_counter_audit",[]); echo count($audit) . PHP_EOL; print_r(end($audit));'
2
2
Array
(
[post_id] => 1360
[counter] => 2
[saved_at] => 2026-02-23T14:22:18+00:00
)
Any acf/save_post callback that writes fields must include recursion guards or temporary unhooking.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Using broad global hooks for targeted logic | Convenience over scope discipline | Unrelated fields mutate unpredictably | Use targeted hooks like acf/load_field/name=... |
| No explicit priorities for interacting callbacks | Default order assumptions | Inconsistent behavior across plugin/theme load orders | Set and document priority numbers per callback |
Validation and normalization mixed in save_post | Lifecycle concerns collapsed | Hard-to-test side effects and duplicate logic | Use validate_value + update_value + save_post split |
Writing fields inside save_post without guard | Recursive triggering | Duplicate writes, inflated counters, performance hit | Remove/re-add action or static in-progress flag |
| Heavy external calls in load filters | Hook called frequently during editor load | Slow admin forms and degraded authoring UX | Cache or precompute data outside hot hook path |
| No runtime hook registration checks in release process | Hook map drift remains invisible | Late discovery after deploy | Add wp eval checks for has_action / has_filter |
Deep Dive: Why Priority Bugs Are Harder to Debug Than They Look
Priority bugs rarely fail consistently because callback order can vary with plugin/theme load sequence and active module combinations. A callback that appears to "work locally" may execute before required setup in another environment. This produces data-dependent failures that look random. The fastest path is to make priority decisions explicit and verify final transformed values through CLI in each environment. Priorities are part of your integration contract, not implementation detail.
wp eval '$field=["name"=>"service_tier","choices"=>[]]; $field=apply_filters("acf/load_field/name=service_tier",$field); print_r($field["choices"]);'
Best Practices
- Attach logic to the narrowest correct lifecycle stage, not the most convenient hook.
- Set explicit priority values whenever two callbacks share a target hook.
- Scope production callbacks by
name=orkey=hook variants whenever possible. - Keep
acf/update_valuecallbacks idempotent and free of external side effects. - Guard
acf/save_postcallbacks against recursion when writing field values. - Add CLI hook-registration and transform-output checks to release smoke tests.
- Maintain a hook map document that links callback file, hook target, and priority.
Hands-On Practice
Exercise 1: Register priority-stacked load_field callbacks
Create wp-content/themes/clinic-pro/inc/acf/hook-priority-map.php and run:
wp eval 'echo has_filter("acf/load_field/name=service_tier") ? "yes" : "no"; echo PHP_EOL;'
After completing this exercise, output should be:
yes
Exercise 2: Verify transformed field choices order
Run:
wp eval '$field=["name"=>"service_tier","choices"=>[]]; $field=apply_filters("acf/load_field/name=service_tier",$field); print_r(array_keys($field["choices"]));'
After completing this exercise, output should contain expected final keys:
enterprise
growth
starter
Exercise 3: Implement lifecycle split (validate + update + save)
Create wp-content/themes/clinic-pro/inc/acf/lifecycle-separation.php and run:
wp eval '$valid=apply_filters("acf/validate_value/name=lead_email",true,"sales@example.com",["name"=>"lead_email"],"acf[field_x]"); var_export($valid); echo PHP_EOL;'
wp eval '$normalized=apply_filters("acf/update_value/name=lead_phone","(65) 8123-9999",1360,["name"=>"lead_phone"],"(65) 8123-9999"); var_export($normalized); echo PHP_EOL;'
After completing this exercise, output should be:
true
+6581239999
Exercise 4: Test robust save_post recursion guard
Create robust save callback and run:
wp eval 'do_action("acf/save_post",1360); do_action("acf/save_post",1360); echo (int)get_field("save_counter",1360) . PHP_EOL;'
After completing this exercise, output should increment deterministically without runaway recursion:
2
Exercise 5: Run hook lifecycle smoke gate
Run:
wp eval 'echo "init=" . (int) has_action("acf/init") . PHP_EOL; echo "load_field=" . (int) has_filter("acf/load_field/name=service_tier") . PHP_EOL; echo "validate=" . (int) has_filter("acf/validate_value/name=lead_email") . PHP_EOL; echo "update=" . (int) has_filter("acf/update_value/name=lead_phone") . PHP_EOL; echo "save_post=" . (int) has_action("acf/save_post") . PHP_EOL;'
After completing this exercise, output pattern should be:
init=1
load_field=1
validate=1
update=1
save_post=1
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval 'echo has_filter("acf/load_field/name=service_tier") ? "yes" : "no"; echo PHP_EOL;' | Verify targeted load_field hook registration | yes |
wp eval '$field=["name"=>"service_tier","choices"=>[]]; $field=apply_filters("acf/load_field/name=service_tier",$field); print_r($field["choices"]);' | Validate final choice payload after priority chain | Array ( [enterprise] => Enterprise ... ) |
wp eval '$v=apply_filters("acf/validate_value/name=lead_email",true,"sales@example.com",["name"=>"lead_email"],"acf[field_x]"); var_export($v);' | Test validate_value callback behavior | true |
wp eval '$v=apply_filters("acf/update_value/name=lead_phone","(65) 8123-9999",1360,["name"=>"lead_phone"],"(65) 8123-9999"); var_export($v);' | Test normalization in update_value | '+6581239999' |
wp eval 'do_action("acf/save_post",1360); print_r(get_option("acf_lifecycle_audit",[]));' | Inspect post-save audit side effects | Array ( [0] => Array ( [post_id] => 1360 ... ) ) |
wp eval 'echo has_action("acf/init") ? "hooked" : "missing"; echo PHP_EOL;' | Check acf/init action registration presence | hooked |
wp eval 'echo has_filter("acf/format_value") ? "yes" : "no"; echo PHP_EOL;' | Confirm format_value hooks are wired when expected | yes or no |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro runtime dependency |
What's Next
- Continue to Field Retrieval APIs and Context-Aware Data Access.
- Return to Module 6 Overview for lifecycle/API context.
- Related lesson: load_field, prepare_field, and Dynamic Choice Population.
- Related lesson: load_value, update_value, and format_value Filters.
Revisit this lesson when adding a new ACF integration callback in a mature codebase; priority and lifecycle placement errors are easiest to fix before merge.