validate_value, Sanitization, and Publish Guards
Strong ACF validation means bad data never reaches storage or publication workflows.
You will implement field-level and cross-field validation with acf/validate_value, enforce canonical storage with sanitization callbacks, and build publish guards that block risky content states before they reach production.
Concept Overview
ACF provides field-level constraints, but real business rules often span multiple fields and publication state. Examples include start/end date logic, legal disclaimers, compliance ownership requirements, and conditional mandatory fields. These rules belong in server-side hooks, not editorial memory.
Validation and sanitization are related but distinct. Validation decides if input is acceptable. Sanitization converts acceptable input into canonical storage shape. Mixing them can produce ambiguous behavior and hidden data mutations.
Publish guards are policy enforcement points at or after save. They are useful when rule outcomes depend on post status transitions, role, or composite field readiness. Good guardrails provide actionable error feedback and observable logs so content teams can self-correct quickly.
Validate business rules before save, sanitize canonical values on update, and guard publication pathways when compliance conditions are unmet.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
| Enforce cross-field rules on submit | Invalid combinations are blocked early | Higher data quality and fewer rollback edits |
| Sanitize canonical values before storage | Query and export behavior remains consistent | Better integration reliability |
| Apply publish guards for compliance-sensitive fields | Risky content states are prevented from release | Lower legal/operational incident risk |
| Return clear, field-targeted messages | Editors fix issues immediately | Lower support load and faster publishing |
| Rely on manual QA only (wrong pattern) | Invalid data leaks under deadline pressure | Expensive post-release corrections |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/validate_value | add_filter('acf/validate_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Reject invalid values pre-save | Return true or string error message |
| Name-targeted validation | acf/validate_value/name=campaign_end_date | Field-specific rule enforcement | Preferred for readability |
| Key-targeted validation | acf/validate_value/key=field_campaign_end_date | Immutable target precision | Useful in refactor-heavy projects |
acf/update_value | add_filter('acf/update_value', callable $callback, int $priority = 10, int $accepted_args = 4): void | Canonical sanitization before write | Keep pure and idempotent |
acf/save_post | add_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): void | Post-save publication guardrails | Use cautiously, avoid recursion |
acf_add_validation_error() | acf_add_validation_error(string $input, string $message): void | Add targeted validation feedback | Useful in custom form workflows |
wp_update_post() | `wp_update_post(array $postarr, bool $wp_error = false): int | WP_Error` | Adjust post status in publish guard flows |
wp eval | wp eval '<php-code>' | Validate hook behavior and sanitization outputs | Core CLI QA path |
Hook Targeting Syntax
| Variant | Example | When to Use |
|---|---|---|
| Global validate | acf/validate_value | Rare broad instrumentation |
| By name | acf/validate_value/name=lead_email | Most custom business rules |
| By key | acf/validate_value/key=field_lead_email | Strict target safety in large teams |
| Global update | acf/update_value | Shared sanitizer dispatch patterns |
Practical Use Cases
Use Case 1 — Block invalid campaign date windows (cross-field validation)
A campaign page cannot be published with campaign_end_date earlier than campaign_start_date. Native single-field constraints are insufficient because this is a cross-field rule.
- Target
campaign_end_dateinvalidate_value. - Read submitted start date from request payload safely.
- Parse both dates and compare timestamps.
- Return actionable error when invalid.
- Verify rule through CLI filter simulation.
<?php
declare(strict_types=1);
add_filter('acf/validate_value/name=campaign_end_date', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$startRaw = '';
if (isset($_POST['acf']) && is_array($_POST['acf'])) {
$startRaw = (string) ($_POST['acf']['field_campaign_start_date'] ?? '');
}
$endRaw = (string) $value;
if ($startRaw === '' || $endRaw === '') {
return true;
}
$start = strtotime($startRaw);
$end = strtotime($endRaw);
if ($start === false || $end === false) {
return 'Campaign dates must be valid date values.';
}
if ($end < $start) {
return 'Campaign end date must be later than campaign start date.';
}
return true;
}, 10, 4);
add_filter('acf/update_value/name=campaign_slug', function ($value, $post_id, $field, $original) {
return sanitize_title((string) $value);
}, 10, 4);
wp eval '$_POST["acf"]["field_campaign_start_date"]="2026-03-10"; $valid=apply_filters("acf/validate_value/name=campaign_end_date", true, "2026-03-09", ["name"=>"campaign_end_date"], "acf[field_campaign_end_date]"); var_export($valid); echo PHP_EOL;'
wp eval '$_POST["acf"]["field_campaign_start_date"]="2026-03-10"; $valid=apply_filters("acf/validate_value/name=campaign_end_date", true, "2026-03-20", ["name"=>"campaign_end_date"], "acf[field_campaign_end_date]"); var_export($valid); echo PHP_EOL;'
'Campaign end date must be later than campaign start date.'
true
Cross-field validation should not depend on client-side checks. Always enforce server-side in hooks.
Use Case 2 — Require legal disclaimer before publish and sanitize policy URL
Healthcare content must include legal disclaimer text and a valid policy URL before publication. Drafts can be incomplete, but publish transitions must be guarded.
- Validate policy URL format on submit.
- Sanitize URL before storage.
- On save, detect publish state and required disclaimer presence.
- If missing, demote post to draft and record reason.
- Expose guard results in option report for release review.
<?php
declare(strict_types=1);
add_filter('acf/validate_value/name=legal_policy_url', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$url = trim((string) $value);
if ($url === '') {
return true;
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return 'Legal policy URL must be a valid URL.';
}
return true;
}, 10, 4);
add_filter('acf/update_value/name=legal_policy_url', function ($value, $post_id, $field, $original) {
return esc_url_raw((string) $value);
}, 20, 4);
add_action('acf/save_post', function ($post_id): void {
if (!is_numeric($post_id)) {
return;
}
static $guardInProgress = false;
if ($guardInProgress) {
return;
}
$postId = (int) $post_id;
$post = get_post($postId);
if (!$post instanceof WP_Post || $post->post_type !== 'page') {
return;
}
if ($post->post_status !== 'publish') {
return;
}
$disclaimer = trim((string) get_field('legal_disclaimer_text', $postId));
$policyUrl = trim((string) get_field('legal_policy_url', $postId));
if ($disclaimer !== '' && $policyUrl !== '') {
return;
}
$guardInProgress = true;
wp_update_post([
'ID' => $postId,
'post_status' => 'draft',
]);
$report = get_option('acf_publish_guard_report', []);
if (!is_array($report)) {
$report = [];
}
$report[] = [
'post_id' => $postId,
'reason' => 'missing_compliance_fields',
'disclaimer_present' => $disclaimer !== '',
'policy_url_present' => $policyUrl !== '',
'at' => gmdate('c'),
];
update_option('acf_publish_guard_report', array_slice($report, -100), false);
$guardInProgress = false;
}, 30);
wp post create --post_title="Compliance Guard Drill" --post_status=publish --post_type=page
wp eval '$id=(int)get_page_by_title("Compliance Guard Drill", OBJECT, "page")->ID; update_field("legal_disclaimer_text","",$id); update_field("legal_policy_url","",$id); do_action("acf/save_post",$id); $p=get_post($id); echo $p->post_status . PHP_EOL;'
wp eval 'print_r(get_option("acf_publish_guard_report",[]));'
Success: Created post 1380.
draft
Array
(
[0] => Array
(
[post_id] => 1380
[reason] => missing_compliance_fields
[disclaimer_present] =>
[policy_url_present] =>
[at] => 2026-02-23T15:02:11+00:00
)
)
Publish guards should be explicit policy controls, not hidden side effects. Always log why a post was blocked.
Use Case 3 — Edge case: sanitization only in template allows bad data in DB
A team sanitizes URLs only during rendering, so invalid values are still stored. Downstream exports and integrations consume bad data. Robust pattern sanitizes at update stage and validates on submit.
❌ Fragile Pattern
<?php
declare(strict_types=1);
add_action('template_redirect', function (): void {
if (!is_page()) {
return;
}
$url = (string) get_field('legal_policy_url');
echo '<a href="' . esc_url($url) . '">Policy</a>';
});
✅ Robust Pattern
<?php
declare(strict_types=1);
add_filter('acf/validate_value/name=legal_policy_url', function ($valid, $value, $field, $input) {
if ($valid !== true) {
return $valid;
}
$url = trim((string) $value);
if ($url === '') {
return true;
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return 'Please enter a valid legal policy URL (including protocol).';
}
return true;
}, 10, 4);
add_filter('acf/update_value/name=legal_policy_url', function ($value, $post_id, $field, $original) {
return esc_url_raw(trim((string) $value));
}, 20, 4);
add_filter('acf/format_value/name=legal_policy_url', function ($value, $post_id, $field) {
$url = esc_url((string) $value);
return $url;
}, 10, 3);
wp eval '$valid=apply_filters("acf/validate_value/name=legal_policy_url", true, "not-a-url", ["name"=>"legal_policy_url"], "acf[field_legal_url]"); var_export($valid); echo PHP_EOL;'
wp eval '$san=apply_filters("acf/update_value/name=legal_policy_url","https://example.com/policy?x=<script>",1380,["name"=>"legal_policy_url"],"https://example.com/policy?x=<script>"); var_export($san); echo PHP_EOL;'
wp eval 'update_field("legal_policy_url","https://example.com/policy?x=<script>",1380); echo get_field("legal_policy_url",1380,false) . PHP_EOL;'
'Please enter a valid legal policy URL (including protocol).'
'https://example.com/policy?x=%3Cscript%3E'
https://example.com/policy?x=%3Cscript%3E
Template-time escaping protects output, not stored data quality. Canonical sanitization must happen before storage.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Cross-field rules implemented only in frontend JS | Trusting client-side validation | Invalid payloads saved via API/CLI | Enforce in acf/validate_value server-side |
| Sanitizing only at render time | Storage integrity ignored | Exports/integrations consume malformed values | Sanitize in acf/update_value |
| Generic error messages | Validation UX not designed | Editors cannot self-correct quickly | Return specific actionable error text |
| Publish guard without recursion protection | Status updates retrigger hooks | Repeated updates and noisy logs | Use guard flags and targeted post-type checks |
| Blocking publish with no audit trail | Operational observability gap | Hard to debug why content remains draft | Persist structured guard reports in option/logs |
| Unscoped validation filters | Over-broad callback behavior | Unexpected failures on unrelated fields | Use name= or key= hook targeting |
Deep Dive: Why Publish Guard Bugs Are Hard to Diagnose
Publish guard logic often changes post status after save, which can trigger additional hooks and obscure original intent. If you do not log guard reason and target fields, teams see "post reverted to draft" without actionable context. This creates confusion between editorial errors and system errors. The fix is to make guard outcomes explicit in structured report entries and include which required fields failed. Then support teams can diagnose in minutes instead of tracing hook stacks manually.
wp eval 'print_r(get_option("acf_publish_guard_report",[]));'
Best Practices
- Use
validate_valuefor business-rule enforcement and return editor-friendly messages. - Normalize canonical storage in
update_value, never in template output code. - Scope validation hooks to field name/key to avoid collateral effects.
- Add recursion guards for any publish-state adjustments in
acf/save_post. - Persist guard/migration reports for audit and release reviews.
- Test validation rules against draft, scheduled, and publish transitions.
- Include CLI simulation of invalid and valid payload paths in QA checklist.
Hands-On Practice
Exercise 1: Add cross-field campaign date validation
Create wp-content/themes/clinic-pro/inc/acf/campaign-date-validation.php and run:
wp eval '$_POST["acf"]["field_campaign_start_date"]="2026-03-10"; $v=apply_filters("acf/validate_value/name=campaign_end_date",true,"2026-03-09",["name"=>"campaign_end_date"],"acf[field_campaign_end_date]"); var_export($v); echo PHP_EOL;'
After completing this exercise, output should be:
Campaign end date must be later than campaign start date.
Exercise 2: Implement URL sanitization and verify canonical storage
Run:
wp eval 'update_field("legal_policy_url","https://example.com/policy?x=<script>",1380); echo get_field("legal_policy_url",1380,false) . PHP_EOL;'
wp post meta get 1380 legal_policy_url
After completing this exercise, output should show escaped canonical URL:
https://example.com/policy?x=%3Cscript%3E
Exercise 3: Add publish guard and trigger blocked publish
Run:
wp post create --post_title="Publish Guard Drill" --post_status=publish --post_type=page
wp eval '$id=(int)get_page_by_title("Publish Guard Drill", OBJECT, "page")->ID; update_field("legal_disclaimer_text","",$id); update_field("legal_policy_url","",$id); do_action("acf/save_post",$id); echo get_post($id)->post_status . PHP_EOL;'
After completing this exercise, output should be:
draft
Exercise 4: Inspect guard report payload
Run:
wp eval 'print_r(get_option("acf_publish_guard_report",[]));'
After completing this exercise, output should include reason and field presence booleans:
[reason] => missing_compliance_fields
[disclaimer_present] =>
[policy_url_present] =>
Exercise 5: Build release smoke gate for validation/sanitization hooks
Run:
wp eval 'echo "validate_url=" . (int) has_filter("acf/validate_value/name=legal_policy_url") . PHP_EOL; echo "update_url=" . (int) has_filter("acf/update_value/name=legal_policy_url") . PHP_EOL; echo "save_guard=" . (int) has_action("acf/save_post") . PHP_EOL;'
After completing this exercise, output pattern should be:
validate_url=1
update_url=1
save_guard=1
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval '$_POST["acf"]["field_campaign_start_date"]="2026-03-10"; $v=apply_filters("acf/validate_value/name=campaign_end_date",true,"2026-03-09",["name"=>"campaign_end_date"],"acf[field_campaign_end_date]"); var_export($v);' | Simulate cross-field validation failure | 'Campaign end date must be later than campaign start date.' |
wp eval '$v=apply_filters("acf/validate_value/name=legal_policy_url",true,"not-a-url",["name"=>"legal_policy_url"],"acf[field_legal_url]"); var_export($v);' | Test URL validation rule | 'Legal policy URL must be a valid URL.' |
wp eval '$v=apply_filters("acf/update_value/name=legal_policy_url","https://example.com/policy?x=<script>",1380,["name"=>"legal_policy_url"],"https://example.com/policy?x=<script>"); var_export($v);' | Test update-stage sanitization | 'https://example.com/policy?x=%3Cscript%3E' |
wp post meta get 1380 legal_policy_url | Read raw canonical stored URL | https://example.com/policy?x=%3Cscript%3E |
wp eval 'do_action("acf/save_post",1380); echo get_post(1380)->post_status . PHP_EOL;' | Trigger publish guard and inspect resulting status | draft |
wp eval 'print_r(get_option("acf_publish_guard_report",[]));' | Review guard decision report | Array ( [0] => Array ( [reason] => ... ) ) |
wp eval 'echo has_filter("acf/validate_value/name=campaign_end_date") ? "yes" : "no"; echo PHP_EOL;' | Verify hook registration | yes |
| `wp plugin list --status=active | grep advanced-custom-fields-pro` | Confirm dependency state |
What's Next
- Continue to Bidirectional Relationships with acf/save_post.
- Return to Module 7 Overview for full dynamic UX and guardrail context.
- Related lesson: load_value, update_value, and format_value Filters.
- Related lesson: Migration, Versioning, and Rollback Playbooks.
Revisit this lesson before any content model release that touches legal, compliance, or publication workflow fields.