load_value, update_value, and format_value Filters
Value filters are reliable only when each one owns a single lifecycle responsibility.
You will separate compatibility transforms (load_value), canonical storage normalization (update_value), and presentation-only formatting (format_value), then verify each stage with CLI tests.
Concept Overview
ACF value filters exist at three moments: value enters runtime (load_value), value is written (update_value), and value is prepared for output (format_value). Confusing these stages is the fastest way to create hard-to-debug data bugs.
update_value should normalize canonical storage shape: strip noise, enforce prefixes, cast types, and persist stable values. format_value should never mutate canonical storage. It exists for output representation only. load_value is often used for backward compatibility or migration bridges where old storage formats must be interpreted safely.
A clean strategy means each callback is pure for its stage: predictable input, predictable output, and no unexpected side effects. This improves queryability, keeps migrations reversible, and avoids hidden mutations during rendering.
Store canonical values with update_value, adapt legacy reads with load_value, and shape display output with format_value only.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
Normalize in update_value only | Stored values stay queryable and consistent | Cleaner analytics, safer migrations |
Format in format_value only | Presentation concerns stay in output layer | Lower risk of data corruption |
Use load_value for compatibility bridges | Legacy records remain readable during transitions | Smoother phased migrations |
| Keep callbacks idempotent and scoped | Re-runs and retries remain safe | More reliable release operations |
| Mix normalization and formatting randomly (wrong pattern) | Storage shape drifts and output becomes inconsistent | Expensive cleanup and hidden regressions |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/load_value | add_filter('acf/load_value', callable $callback, int $priority = 10, int $accepted_args = 3): void | Transform values when loading from DB | Use for compatibility, not persistence |
acf/update_value | add_filter('acf/update_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Normalize values before write | Best place for canonical storage rules |
acf/format_value | add_filter('acf/format_value', callable $callback, int $priority = 10, int $accepted_args = 3): void | Format values for output/read consumption | Do not write DB changes here |
acf/validate_value | add_filter('acf/validate_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Reject invalid values before update stage | Pair with update_value for robust pipelines |
update_field() | `update_field(string $selector, mixed $value, int | string $post_id = false): int | bool` |
wp post meta get | wp post meta get <post-id> <meta-key> | Inspect raw stored canonical value | Confirms storage shape after update filter |
wp eval | wp eval '<php-code>' | Test filter outputs directly | Supports stage-by-stage assertions |
| Repeater row filters | acf/update_value/name=service_faq_items | Normalize structured arrays | ACF Pro Required when target is Repeater/Flexible |
Hook Targeting Syntax
Prefer targeted filter variants for precision:
| Variant | Example | Use |
|---|---|---|
| Global | acf/update_value | Broad diagnostics only |
| Name-targeted | acf/update_value/name=sales_phone | Stable field names with ownership policy |
| Key-targeted | acf/update_value/key=field_sales_phone | High-precision production rules |
| Type-targeted | acf/format_value/type=number | Shared formatting policy by type |
Practical Use Cases
Use Case 1 — Normalize phone numbers before save and keep storage canonical
A sales form receives phone numbers in inconsistent formats (+65 8123-9999, 081239999, (65) 8123 9999). You want one canonical format in storage: +65XXXXXXXX.
- Validate minimum digit quality in
validate_value. - Normalize storage in
update_value. - Keep output formatting separate from storage rules.
- Verify raw stored value with
wp post meta get. - Assert filter output through CLI.
<?php
declare(strict_types=1);
add_filter('acf/validate_value/name=sales_phone', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$digits = preg_replace('/\D+/', '', (string) $value);
if (strlen((string) $digits) < 8) {
return 'Phone number must contain at least 8 digits.';
}
return true;
}, 10, 4);
add_filter('acf/update_value/name=sales_phone', function ($value, $post_id, $field, $original) {
$digits = preg_replace('/\D+/', '', (string) $value);
if (str_starts_with((string) $digits, '0')) {
$digits = substr((string) $digits, 1);
}
if (!str_starts_with((string) $digits, '65')) {
$digits = '65' . $digits;
}
$normalized = '+' . $digits;
error_log('[acf-phone-update] post=' . (string) $post_id . ' value=' . $normalized);
return $normalized;
}, 20, 4);
add_filter('acf/format_value/name=sales_phone', function ($value, $post_id, $field) {
$digits = preg_replace('/\D+/', '', (string) $value);
if (strlen((string) $digits) < 10) {
return (string) $value;
}
return '+' . substr((string) $digits, 0, 2) . ' ' . substr((string) $digits, 2, 4) . ' ' . substr((string) $digits, 6);
}, 10, 3);
wp eval '$v=apply_filters("acf/update_value/name=sales_phone","(65) 8123-9999",1360,["name"=>"sales_phone"],"(65) 8123-9999"); var_export($v); echo PHP_EOL;'
wp eval 'update_field("sales_phone","(65) 8123-9999",1360); echo get_field("sales_phone",1360,false) . PHP_EOL;'
wp post meta get 1360 sales_phone
'+6581239999'
+6581239999
+6581239999
If canonical format is not enforced at update stage, every downstream system (CRM sync, analytics, exports) must re-normalize independently.
Use Case 2 — Use load_value as legacy compatibility bridge during migration window
Old records store service status as uppercase words (ACTIVE, PAUSED), while new schema expects lowercase slugs (active, paused). You need temporary compatibility without rewriting all rows immediately.
- Keep canonical writes in lowercase through
update_value. - Add
load_valuemap for legacy uppercase reads. - Keep bridge logic explicit and auditable.
- Add migration report for converted reads.
- Plan bridge removal after full migration.
<?php
declare(strict_types=1);
add_filter('acf/load_value/name=service_status', function ($value, $post_id, $field) {
$raw = (string) $value;
$map = [
'ACTIVE' => 'active',
'PAUSED' => 'paused',
'ARCHIVED' => 'archived',
];
$resolved = $map[$raw] ?? strtolower($raw);
$report = get_option('acf_status_load_bridge_report', []);
if (!is_array($report)) {
$report = [];
}
if ($raw !== $resolved) {
$report[] = [
'post_id' => (int) $post_id,
'raw' => $raw,
'resolved' => $resolved,
'at' => gmdate('c'),
];
update_option('acf_status_load_bridge_report', array_slice($report, -100), false);
}
return $resolved;
}, 10, 3);
add_filter('acf/update_value/name=service_status', function ($value, $post_id, $field, $original) {
return strtolower(trim((string) $value));
}, 10, 4);
add_filter('acf/format_value/name=service_status', function ($value, $post_id, $field) {
$v = (string) $value;
return $v === '' ? '' : ucfirst($v);
}, 10, 3);
wp eval '$v=apply_filters("acf/load_value/name=service_status","ACTIVE",1360,["name"=>"service_status"]); var_export($v); echo PHP_EOL;'
wp eval 'update_field("service_status","PAUSED",1360); echo get_field("service_status",1360,false) . PHP_EOL;'
wp eval 'echo get_field("service_status",1360,true) . PHP_EOL;'
wp eval 'print_r(get_option("acf_status_load_bridge_report",[]));'
'active'
paused
Paused
Array
(
[0] => Array
(
[post_id] => 1360
[raw] => ACTIVE
[resolved] => active
[at] => 2026-02-23T14:41:07+00:00
)
)
Compatibility bridges should be time-bounded. Keep a deprecation date so temporary load logic does not become permanent debt.
Use Case 3 — Edge case: format_value callback mutates storage (anti-pattern)
A callback mistakenly writes back to DB inside format_value, causing side effects during rendering and potential recursion/noise in caches and logs.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_filter('acf/format_value/name=service_slug', function ($value, $post_id, $field) {
$slug = sanitize_title((string) $value);
update_field('service_slug', $slug, (int) $post_id);
return $slug;
}, 10, 3);
✅ Robust Pattern
<?php
declare(strict_types=1);
add_filter('acf/update_value/name=service_slug', function ($value, $post_id, $field, $original) {
return sanitize_title((string) $value);
}, 10, 4);
add_filter('acf/format_value/name=service_slug', function ($value, $post_id, $field) {
$slug = sanitize_title((string) $value);
$display = str_replace('-', ' ', $slug);
return ucwords($display);
}, 10, 3);
add_action('wp', function (): void {
if (!is_singular('page')) {
return;
}
$postId = (int) get_the_ID();
$raw = get_field('service_slug', $postId, false);
$formatted = get_field('service_slug', $postId, true);
error_log('[service-slug] post=' . $postId . ' raw=' . (string) $raw . ' formatted=' . (string) $formatted);
});
wp eval 'update_field("service_slug","Laser Hair Removal",1360); echo get_field("service_slug",1360,false) . PHP_EOL;'
wp eval 'echo get_field("service_slug",1360,true) . PHP_EOL;'
wp post meta get 1360 service_slug
laser-hair-removal
Laser Hair Removal
laser-hair-removal
format_value must stay pure. Any writes belong in update_value or explicit migration scripts.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
Normalizing values in format_value | Lifecycle misunderstanding | Canonical storage drifts unpredictably | Normalize only in update_value |
Writing DB inside format_value callback | Side-effect coding style | Hidden writes during read operations | Keep format_value read-only and pure |
| Using unscoped global filters for critical fields | Convenience over precision | Unintended transformations on unrelated fields | Use name= or key= targeted filters |
| No validation before update normalization | Invalid values pass through normalization | Corrupted or misleading canonical data | Pair validate_value with update_value |
| No raw-vs-formatted verification checks | Teams assume output equals storage | Migration mistakes go unnoticed | Compare get_field(..., false) vs get_field(..., true) |
| Permanent legacy bridges with no sunset | Temporary compatibility never removed | Long-term technical debt and ambiguity | Track bridge reports and remove after migration |
Deep Dive: Why Mixing update_value and format_value Logic Is Costly
When storage normalization and display formatting are mixed, developers lose a stable canonical source. Query behavior becomes inconsistent, data exports become unreliable, and migration assumptions fail. It also makes debugging harder because a value may appear one way in templates and another way in raw meta without clear policy. Keeping these stages separate gives you one truth for storage and one controlled representation for UI. This separation is foundational for analytics, integrations, and rollback safety.
wp eval '$raw=get_field("service_slug",1360,false); $fmt=get_field("service_slug",1360,true); echo "raw=" . $raw . PHP_EOL; echo "fmt=" . $fmt . PHP_EOL;'
Best Practices
- Define canonical storage contract first, then write
update_valueto enforce it. - Use
validate_valueto reject malformed inputs before normalization runs. - Keep
format_valuepure and side-effect free (no writes, no network calls). - Scope filters by field name/key for production-critical transformations.
- Add CLI tests that compare raw stored value vs formatted output.
- Track legacy load bridges with report options and deprecation timeline.
- Document filter ownership (file, target, priority, purpose) for maintainability.
Hands-On Practice
Exercise 1: Add phone validation + normalization filters
Create wp-content/themes/clinic-pro/inc/acf/phone-normalization.php and run:
wp eval '$normalized=apply_filters("acf/update_value/name=sales_phone","(65) 8123-9999",1360,["name"=>"sales_phone"],"(65) 8123-9999"); echo $normalized . PHP_EOL;'
After completing this exercise, output should be:
+6581239999
Exercise 2: Verify canonical storage and formatted output diverge intentionally
Run:
wp eval 'update_field("sales_phone","(65) 8123-9999",1360); echo get_field("sales_phone",1360,false) . PHP_EOL; echo get_field("sales_phone",1360,true) . PHP_EOL;'
After completing this exercise, output should be:
+6581239999
+65 8123 9999
Exercise 3: Implement and test legacy load_value bridge
Create compatibility bridge file and run:
wp eval '$resolved=apply_filters("acf/load_value/name=service_status","ACTIVE",1360,["name"=>"service_status"]); echo $resolved . PHP_EOL;'
After completing this exercise, output should be:
active
Exercise 4: Inspect migration bridge report option
Run:
wp eval 'print_r(get_option("acf_status_load_bridge_report",[]));'
After completing this exercise, output should include at least one compatibility conversion record:
[raw] => ACTIVE
[resolved] => active
Exercise 5: Confirm no mutation in format_value path
Run:
wp eval 'update_field("service_slug","Laser Hair Removal",1360); $raw1=get_field("service_slug",1360,false); $fmt=get_field("service_slug",1360,true); $raw2=get_field("service_slug",1360,false); echo $raw1 . "|" . $fmt . "|" . $raw2 . PHP_EOL;'
After completing this exercise, output should show raw storage unchanged before and after formatting:
laser-hair-removal|Laser Hair Removal|laser-hair-removal
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval '$v=apply_filters("acf/update_value/name=sales_phone","(65) 8123-9999",1360,["name"=>"sales_phone"],"(65) 8123-9999"); var_export($v);' | Test update-stage normalization result | '+6581239999' |
wp eval 'update_field("sales_phone","(65) 8123-9999",1360); echo get_field("sales_phone",1360,false) . PHP_EOL;' | Verify canonical raw storage | +6581239999 |
wp eval 'echo get_field("sales_phone",1360,true) . PHP_EOL;' | Verify format-stage display rendering | +65 8123 9999 |
wp eval '$v=apply_filters("acf/load_value/name=service_status","ACTIVE",1360,["name"=>"service_status"]); var_export($v);' | Test load-stage compatibility conversion | 'active' |
wp post meta get 1360 service_slug | Inspect canonical slug storage after update filter | laser-hair-removal |
wp eval 'echo get_field("service_slug",1360,true) . PHP_EOL;' | Inspect presentation formatting for same value | Laser Hair Removal |
wp eval 'print_r(get_option("acf_status_load_bridge_report",[]));' | Inspect compatibility bridge activity logs | Array ( [0] => Array ( [raw] => ACTIVE ... ) ) |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm ACF Pro dependency |
What's Next
- Continue to load_field, prepare_field, and Dynamic Choice Population.
- Return to Module 6 Overview for full lifecycle context.
- Related lesson: validate_value, Sanitization, and Publish Guards.
- Related lesson: Performance, Debugging, Migration, and Launch Checklist.
Revisit this lesson before writing any new ACF value transform; choosing the wrong lifecycle stage is the most expensive avoidable mistake.