Bidirectional Relationships with acf/save_post
Bidirectional syncing turns relationship fields from fragile editor conventions into deterministic data contracts.
You will implement production-safe acf/save_post synchronization that updates reverse links, removes stale links, and avoids recursion while keeping relationship arrays canonical and query-friendly.
Concept Overview
ACF relationship fields often start as a one-way authoring tool. Editors select related records on one post, and templates read that list. In production, this is rarely enough. Search widgets, profile pages, and listing modules usually need the reverse direction too, and manual reverse editing fails under normal editorial load.
acf/save_post is the right integration point for synchronization because it runs after ACF has persisted submitted values. That timing means you can read the latest relationship selection from the source post, compare it against previously stored associations, and update all affected related posts in one transaction-like pass.
A robust pattern handles three states every save: added relations, unchanged relations, and removed relations. Many teams implement only additions and silently leak stale reverse links when authors unselect items. The practical fix is to persist the previous source set in lightweight meta, then compute set differences on each save.
Treat reverse relationship data as a derived index that is rebuilt safely on every save, with explicit add and remove logic plus loop guards.
Why It Matters
| Approach | What Happens | Impact in Production |
|---|---|---|
Sync both additions and removals during acf/save_post | Reverse lists always match editor selection on source record | Related-content widgets are accurate and support tickets drop |
| Only append reverse links and never remove | Old relations remain visible after editor unselects items | Users see stale associations and lose trust in content relevance |
| Use canonical integer arrays with de-duplication | Query code and exports get stable value shape | Faster debugging and lower integration breakage risk |
| Guard callback recursion with explicit lock | Reverse updates do not retrigger endless save chains | Stable save behavior with predictable DB writes |
| Run sync on every post type without gating (wrong pattern) | Unrelated saves trigger expensive relation scans | High write amplification and avoidable performance regressions |
Reference Table
| Term/API | Signature/Syntax | Purpose | Key Notes |
|---|---|---|---|
acf/save_post | add_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): void | Main post-save sync hook | Use numeric post IDs only; ignore option saves |
get_field() | `get_field(string $selector, int | string $post_id = false, bool $format_value = true): mixed` | Read source and reverse relationship values |
update_field() | `update_field(string $selector, mixed $value, int | string $post_id = false): int | bool` |
remove_action() | remove_action(string $hook_name, callable $callback, int $priority = 10): bool | Temporarily unhook callback during reverse updates | Prevents recursive self-trigger behavior |
add_action() | add_action(string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1): true | Reattach callback after guarded writes | Keep priority consistent with removal call |
array_diff() | array_diff(array $array, array $arrays): array | Compute added and removed relation IDs | Core set math for safe bidirectional sync |
wp_is_post_revision() | `wp_is_post_revision(int | WP_Post $post): int | false` |
wp post meta get | wp post meta get <post-id> <meta-key> | Inspect raw stored reverse values | Fast operational verification path |
Hook Targeting Syntax
| Variant | Syntax Example | When to Use |
|---|---|---|
| Global save hook | acf/save_post | Primary place for relationship synchronization |
| Name-targeted load hook | acf/load_value/name=event_speakers | Optional preprocessing before comparisons |
| Key-targeted save variant | acf/save_post + field key checks inside callback | Useful when field names may change |
| Post type gating pattern | if (get_post_type($post_id) !== 'event') { return; } | Mandatory in shared admin environments |
If you model this with the Relationship field type, the field itself is an ACF Pro feature.
Practical Use Cases
1. Event to Speaker two-way sync with add and remove handling
A conference site stores selected speakers on each event post in event_speakers. Speaker pages use speaker_events for profile cards. Editors should update only the event, and the speaker reverse field should stay correct when speakers are added or removed.
- Register one
acf/save_postcallback foreventposts. - Read current
event_speakersIDs and previously synced IDs from meta snapshot. - Compute added and removed IDs using set difference.
- Append event ID to each added speaker reverse list.
- Remove event ID from each removed speaker reverse list.
- Persist new snapshot for next save cycle.
<?php
declare(strict_types=1);
add_action('acf/save_post', 'conference_sync_event_speakers', 20);
function conference_sync_event_speakers($post_id): void
{
if (!is_numeric($post_id)) {
return;
}
$eventId = (int) $post_id;
if (wp_is_post_revision($eventId)) {
return;
}
if (get_post_type($eventId) !== 'event') {
return;
}
$current = get_field('event_speakers', $eventId, false);
$currentIds = array_values(array_unique(array_map('intval', is_array($current) ? $current : [])));
$previous = get_post_meta($eventId, '_event_speakers_synced_snapshot', true);
$previousIds = array_values(array_unique(array_map('intval', is_array($previous) ? $previous : [])));
$added = array_values(array_diff($currentIds, $previousIds));
$removed = array_values(array_diff($previousIds, $currentIds));
remove_action('acf/save_post', 'conference_sync_event_speakers', 20);
foreach ($added as $speakerId) {
$reverse = get_field('speaker_events', $speakerId, false);
$reverseIds = array_values(array_unique(array_map('intval', is_array($reverse) ? $reverse : [])));
if (!in_array($eventId, $reverseIds, true)) {
$reverseIds[] = $eventId;
}
sort($reverseIds);
update_field('speaker_events', $reverseIds, $speakerId);
}
foreach ($removed as $speakerId) {
$reverse = get_field('speaker_events', $speakerId, false);
$reverseIds = array_values(array_unique(array_map('intval', is_array($reverse) ? $reverse : [])));
$reverseIds = array_values(array_filter(
$reverseIds,
static fn (int $id): bool => $id !== $eventId
));
sort($reverseIds);
update_field('speaker_events', $reverseIds, $speakerId);
}
update_post_meta($eventId, '_event_speakers_synced_snapshot', $currentIds);
add_action('acf/save_post', 'conference_sync_event_speakers', 20);
}
wp eval '$event=2201; update_field("event_speakers", [3301,3302], $event); do_action("acf/save_post", $event); print_r(get_field("speaker_events",3301,false));'
wp eval '$event=2201; update_field("event_speakers", [3302], $event); do_action("acf/save_post", $event); print_r(get_field("speaker_events",3301,false));'
Array
(
[0] => 2201
)
Array
(
)
If you do not persist a snapshot (_event_speakers_synced_snapshot), you cannot reliably detect removals after editor unselects a related record.
2. Staff profile to service page synchronization with canonical ordering
A clinic site uses service_staff on service pages and staff_services on staff profiles. The site search index reads staff_services directly, so order and duplicate handling must be deterministic to avoid noisy diffs and unstable caches.
- Gate callback to
serviceposts only. - Normalize
service_staffinto sorted integer IDs. - Update reverse
staff_serviceslists with de-duplication. - Remove stale links for staff no longer selected.
- Save a canonical JSON log payload for observability.
<?php
declare(strict_types=1);
add_action('acf/save_post', function ($post_id): void {
if (!is_numeric($post_id)) {
return;
}
$serviceId = (int) $post_id;
if (get_post_type($serviceId) !== 'service') {
return;
}
static $lock = false;
if ($lock) {
return;
}
$current = get_field('service_staff', $serviceId, false);
$currentIds = array_values(array_unique(array_map('intval', is_array($current) ? $current : [])));
sort($currentIds);
$previous = get_post_meta($serviceId, '_service_staff_synced_snapshot', true);
$previousIds = array_values(array_unique(array_map('intval', is_array($previous) ? $previous : [])));
sort($previousIds);
$added = array_values(array_diff($currentIds, $previousIds));
$removed = array_values(array_diff($previousIds, $currentIds));
$lock = true;
foreach ($added as $staffId) {
$reverse = get_field('staff_services', $staffId, false);
$reverseIds = array_values(array_unique(array_map('intval', is_array($reverse) ? $reverse : [])));
$reverseIds[] = $serviceId;
$reverseIds = array_values(array_unique($reverseIds));
sort($reverseIds);
update_field('staff_services', $reverseIds, $staffId);
}
foreach ($removed as $staffId) {
$reverse = get_field('staff_services', $staffId, false);
$reverseIds = array_values(array_unique(array_map('intval', is_array($reverse) ? $reverse : [])));
$reverseIds = array_values(array_filter(
$reverseIds,
static fn (int $id): bool => $id !== $serviceId
));
sort($reverseIds);
update_field('staff_services', $reverseIds, $staffId);
}
update_post_meta($serviceId, '_service_staff_synced_snapshot', $currentIds);
update_post_meta($serviceId, '_service_staff_sync_log', wp_json_encode([
'service_id' => $serviceId,
'added' => $added,
'removed' => $removed,
'synced_at' => gmdate('c'),
]));
$lock = false;
}, 25);
wp eval '$service=4401; update_field("service_staff", [5101,5102,5102], $service); do_action("acf/save_post", $service); print_r(get_field("staff_services",5101,false));'
wp post meta get 4401 _service_staff_sync_log
Array
(
[0] => 4401
)
{"service_id":4401,"added":[5101,5102],"removed":[],"synced_at":"2026-02-23T18:15:20+00:00"}
Do not store unsorted relationship arrays if you diff or cache these values. Non-deterministic ordering causes false-positive changes.
3. Edge case: deleted related posts leave orphan IDs in reverse arrays
A common failure is assuming all related IDs still exist. If a related profile is deleted or moved to trash, naive sync code can keep orphan IDs in reverse fields. That breaks template loops and wastes DB reads.
Fragile Pattern (Bad)
<?php
declare(strict_types=1);
add_action('acf/save_post', function ($post_id): void {
$related = get_field('service_staff', (int) $post_id, false);
foreach ((array) $related as $staffId) {
$reverse = get_field('staff_services', (int) $staffId, false);
$reverse[] = (int) $post_id;
update_field('staff_services', $reverse, (int) $staffId);
}
}, 20);
Robust Pattern (Good)
<?php
declare(strict_types=1);
add_action('acf/save_post', function ($post_id): void {
if (!is_numeric($post_id)) {
return;
}
$serviceId = (int) $post_id;
if (get_post_type($serviceId) !== 'service') {
return;
}
$staff = get_field('service_staff', $serviceId, false);
$staffIds = array_values(array_unique(array_map('intval', is_array($staff) ? $staff : [])));
foreach ($staffIds as $staffId) {
$staffPost = get_post($staffId);
if (!$staffPost instanceof WP_Post || $staffPost->post_status === 'trash') {
continue;
}
$reverse = get_field('staff_services', $staffId, false);
$reverseIds = array_values(array_unique(array_map('intval', is_array($reverse) ? $reverse : [])));
$reverseIds = array_values(array_filter(
$reverseIds,
static function (int $id): bool {
$target = get_post($id);
return $target instanceof WP_Post && $target->post_status !== 'trash';
}
));
if (!in_array($serviceId, $reverseIds, true)) {
$reverseIds[] = $serviceId;
}
$reverseIds = array_values(array_unique($reverseIds));
sort($reverseIds);
update_field('staff_services', $reverseIds, $staffId);
}
}, 30);
wp eval '$staff=5109; wp_trash_post($staff); $service=4401; update_field("service_staff", [$staff,5110], $service); do_action("acf/save_post", $service); print_r(get_field("staff_services",5110,false));'
wp eval '$raw=get_field("service_staff",4401,false); print_r($raw);'
Array
(
[0] => 4401
)
Array
(
[0] => 5109
[1] => 5110
)
Source-side arrays may still contain trashed IDs if editors have not cleaned selection; robust reverse sync must validate target post existence before writing.
Common Mistakes
| Mistake | Root Cause | What Breaks in Production | Correct Pattern |
|---|---|---|---|
| Sync callback runs for every save type including options and revisions | No numeric post ID and revision checks | Extra writes and inconsistent relation state | if (!is_numeric($post_id) or wp_is_post_revision((int) $post_id)) { return; } |
| Only handling newly added related IDs | No previous snapshot for diffing | Removed relations remain visible forever | Save _event_speakers_synced_snapshot and use array_diff() for removals |
| Missing recursion lock around reverse updates | Callback triggers itself through nested updates | Infinite or noisy save loops | static $lock=false; if ($lock) return; $lock=true; update_field('staff_services',$reverseIds,$staffId); $lock=false; |
| Writing mixed string and integer IDs | No normalization before update_field() | Equality checks fail and duplicate IDs appear | array_map('intval', $ids) then array_unique() before save |
| Storing non-deterministic relation order | No sorting before persist | Cache invalidation churn and noisy commits | sort($ids); update_field('staff_services', $ids, $staffId); |
| Ignoring deleted/trashed related posts | Assumes ID validity forever | Template loops hit missing posts and empty cards | Filter IDs with get_post($id) and status checks before write |
Deep Dive: Why Removal Sync Bugs Are Harder to Debug Than They Look
Removal bugs hide because the source record often looks correct in wp-admin while reverse widgets remain stale on other pages. Teams test only forward add flows, so the missing remove path is invisible until content audits or user reports. Caches can also mask timing, making stale links appear intermittent. The reliable way to diagnose is to compare current source IDs, previous snapshot IDs, and reverse field arrays in one CLI inspection run. If removed IDs are never present in your array_diff($previous, $current) set, your snapshot strategy is broken.
wp eval '$id=2201; $current=get_field("event_speakers",$id,false); $prev=get_post_meta($id,"_event_speakers_synced_snapshot",true); var_export(["current"=>$current,"previous"=>$prev]); echo PHP_EOL;'
Best Practices
- Gate every sync callback by post type and ID shape:
wp eval 'echo get_post_type(2201) . PHP_EOL;'should return the expected source type. - Keep one snapshot meta key per source relationship and verify with
wp post meta get 2201 _event_speakers_synced_snapshot. - Normalize relationship values into integer arrays before set math:
wp eval '$v=["9","9",10]; print_r(array_values(array_unique(array_map("intval",$v))));'. - Sort reverse arrays before
update_field()so serialized values are stable and diff-friendly. - Add one observability meta log per sync run and inspect with
wp post meta get <id> _service_staff_sync_logduring QA. - Use revision and autosave guards to avoid syncing temporary editor states.
- Run add, remove, and no-op test paths from CLI before release; all three must produce deterministic outputs.
Hands-On Practice
Exercise 1: Create source and related posts for sync drills
Run:
wp post create --post_title="Event Sync Drill" --post_status=publish --post_type=event
wp post create --post_title="Speaker A" --post_status=publish --post_type=speaker
wp post create --post_title="Speaker B" --post_status=publish --post_type=speaker
After completing this exercise, running wp post list --post_type=event,speaker --fields=ID,post_title --format=table should return one event and two speaker rows.
Exercise 2: Implement add-path synchronization
Create wp-content/themes/conference-pro/inc/acf/event-speaker-sync.php with the Use Case 1 callback, then run:
wp eval '$event=(int)get_page_by_title("Event Sync Drill",OBJECT,"event")->ID; $a=(int)get_page_by_title("Speaker A",OBJECT,"speaker")->ID; update_field("event_speakers", [$a], $event); do_action("acf/save_post", $event); print_r(get_field("speaker_events",$a,false));'
After completing this exercise, output should be:
Array
(
[0] => <event-id>
)
Exercise 3: Verify remove-path synchronization
Run:
wp eval '$event=(int)get_page_by_title("Event Sync Drill",OBJECT,"event")->ID; $a=(int)get_page_by_title("Speaker A",OBJECT,"speaker")->ID; update_field("event_speakers", [], $event); do_action("acf/save_post", $event); print_r(get_field("speaker_events",$a,false));'
After completing this exercise, output should be:
Array
(
)
Exercise 4: Validate canonical ordering and de-duplication
Run:
wp eval '$service=4401; update_field("service_staff", [5102,5101,5101], $service); do_action("acf/save_post", $service); $v=get_post_meta($service,"_service_staff_synced_snapshot",true); print_r($v);'
After completing this exercise, output should show one instance per ID in stable order:
Array
(
[0] => 5101
[1] => 5102
)
Exercise 5: Build a pre-release sync smoke command
Run:
wp eval 'echo "save_hook=" . (int) has_action("acf/save_post") . PHP_EOL; echo "snapshot=" . (int) metadata_exists("post",2201,"_event_speakers_synced_snapshot") . PHP_EOL;'
After completing this exercise, output pattern should be:
save_hook=1
snapshot=1
CLI Reference
| Command | Purpose | Real Example Output |
|---|---|---|
wp eval '$id=2201; print_r(get_field("event_speakers",$id,false));' | Read source relationship IDs | Array ( [0] => 3301 [1] => 3302 ) |
wp eval '$id=3301; print_r(get_field("speaker_events",$id,false));' | Read reverse relationship IDs | Array ( [0] => 2201 ) |
wp post meta get 2201 _event_speakers_synced_snapshot | Inspect previous snapshot for removal diffing | a:2:{i:0;i:3301;i:1;i:3302;} |
wp eval '$e=2201; update_field("event_speakers", [3302], $e); do_action("acf/save_post", $e); echo "done" . PHP_EOL;' | Trigger sync after removing one relation | done |
wp eval 'echo has_action("acf/save_post") ? "registered" : "missing"; echo PHP_EOL;' | Confirm save hook registration | registered |
wp eval '$id=4401; echo get_post_meta($id,"_service_staff_sync_log",true) . PHP_EOL;' | Read observability sync log payload | {"service_id":4401,"added":[5101,5102],"removed":[],"synced_at":"2026-02-23T18:15:20+00:00"} |
wp post meta get 5101 staff_services | Inspect raw reverse meta for one related post | a:1:{i:0;i:4401;} |
ls wp-content/themes/conference-pro/inc/acf/ | Verify sync callback file exists in theme | event-speaker-sync.php |
What's Next
- Continue to Module 8 Lesson 1: Exposing ACF in REST API Securely.
- Return to Module 7 Overview for the full admin UX and validation hook sequence.
- Related lesson: load_value, update_value, and format_value Filters.
- Related lesson: Caching and Query Optimization for Field-Heavy Templates.
Revisit this lesson when editors start reporting stale related cards after unselecting links, because that is the clearest signal your remove-path sync logic needs attention.