Skip to main content

Understanding ACF Hook Lifecycle and Priorities

ACF hook timing determines whether your customization is deterministic or intermittently broken.

Learning Focus

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.

Core Idea

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

ApproachWhat HappensImpact in Production
Use stage-specific hooks (load_field, validate_value, update_value, save_post)Logic executes with correct data availability assumptionsLower regression risk and faster troubleshooting
Put all behavior in acf/save_postValidation, mutation, and side effects become tangledHigh defect rate and difficult incident recovery
Declare explicit priorities where callbacks interactOutcome ordering is deterministicBetter plugin/theme interoperability
Scope hooks by name= or key= targetsOnly intended fields are modifiedFewer accidental cross-field side effects
Use global unscoped hooks without strategy (wrong pattern)Unrelated fields and forms get mutated unexpectedlyHard-to-reproduce bugs and editor mistrust

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf/initadd_action('acf/init', callable $callback, int $priority = 10): voidRegister fields, options pages, block bootstrapsACF APIs are available here
acf/load_fieldadd_filter('acf/load_field', callable $callback, int $priority = 10, int $accepted_args = 1): voidAlter field config before renderPrefer targeted variants by name/key/type
acf/load_valueadd_filter('acf/load_value', callable $callback, int $priority = 10, int $accepted_args = 3): voidTransform value before editor or API consumptionDo not use for persistent writes
acf/validate_valueadd_filter('acf/validate_value', callable $callback, int $priority = 10, int $accepted_args = 4): voidBlock invalid saves with explicit error messageReturn true or string message
acf/update_valueadd_filter('acf/update_value', callable $callback, int $priority = 10, int $accepted_args = 4): voidNormalize value just before storageKeep idempotent and side-effect free
acf/save_postadd_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): voidPerform post-save sync and side effectsGuard against recursion when updating fields
acf/format_valueadd_filter('acf/format_value', callable $callback, int $priority = 10, int $accepted_args = 3): voidPresentation-time formattingNever mutate stored canonical value here
wp evalwp eval '<php-code>'Verify hook registration and callback existenceUseful release smoke check

Hook Targeting Syntax

Use targeted hook variants whenever possible:

VariantExampleWhen to Use
Globalacf/load_fieldVery broad instrumentation or diagnostics only
By nameacf/load_field/name=service_tierStable field names under team governance
By keyacf/load_field/key=field_service_tierMaximum precision when key is immutable
By typeacf/load_field/type=selectCross-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.

  1. Register baseline choices at lower priority.
  2. Register theme refinement callback at higher priority.
  3. Scope both callbacks to the same field target.
  4. Log final choices count for runtime verification.
  5. Verify hook presence/order assumptions via CLI.
wp-content/themes/clinic-pro/inc/acf/hook-priority-map.php
<?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;
}
terminal: command
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"]);'
terminal: output
hooked
Array
(
[enterprise] => Enterprise
[growth] => Growth
[starter] => Starter
)
warning

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.

  1. Validate email/phone format in acf/validate_value.
  2. Normalize phone in acf/update_value before storage.
  3. Record post-save audit event in acf/save_post.
  4. Keep each callback single-purpose.
  5. Verify behavior with WP-CLI filter/application checks.
wp-content/themes/clinic-pro/inc/acf/lifecycle-separation.php
<?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);
terminal: command
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;'
terminal: output
true
'+6581239999'
1
note

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

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

wp-content/themes/clinic-pro/inc/acf/save-post-robust.php
<?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;
}
terminal: command
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));'
terminal: output
2
2
Array
(
[post_id] => 1360
[counter] => 2
[saved_at] => 2026-02-23T14:22:18+00:00
)
warning

Any acf/save_post callback that writes fields must include recursion guards or temporary unhooking.

Common Mistakes

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Using broad global hooks for targeted logicConvenience over scope disciplineUnrelated fields mutate unpredictablyUse targeted hooks like acf/load_field/name=...
No explicit priorities for interacting callbacksDefault order assumptionsInconsistent behavior across plugin/theme load ordersSet and document priority numbers per callback
Validation and normalization mixed in save_postLifecycle concerns collapsedHard-to-test side effects and duplicate logicUse validate_value + update_value + save_post split
Writing fields inside save_post without guardRecursive triggeringDuplicate writes, inflated counters, performance hitRemove/re-add action or static in-progress flag
Heavy external calls in load filtersHook called frequently during editor loadSlow admin forms and degraded authoring UXCache or precompute data outside hot hook path
No runtime hook registration checks in release processHook map drift remains invisibleLate discovery after deployAdd 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

  1. Attach logic to the narrowest correct lifecycle stage, not the most convenient hook.
  2. Set explicit priority values whenever two callbacks share a target hook.
  3. Scope production callbacks by name= or key= hook variants whenever possible.
  4. Keep acf/update_value callbacks idempotent and free of external side effects.
  5. Guard acf/save_post callbacks against recursion when writing field values.
  6. Add CLI hook-registration and transform-output checks to release smoke tests.
  7. 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

CommandPurposeReal Example Output
wp eval 'echo has_filter("acf/load_field/name=service_tier") ? "yes" : "no"; echo PHP_EOL;'Verify targeted load_field hook registrationyes
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 chainArray ( [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 behaviortrue
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 effectsArray ( [0] => Array ( [post_id] => 1360 ... ) )
wp eval 'echo has_action("acf/init") ? "hooked" : "missing"; echo PHP_EOL;'Check acf/init action registration presencehooked
wp eval 'echo has_filter("acf/format_value") ? "yes" : "no"; echo PHP_EOL;'Confirm format_value hooks are wired when expectedyes or no
`wp plugin list --status=activegrep advanced-custom-fields-pro`Confirm ACF Pro runtime dependency

What's Next

tip

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.