Skip to main content

Bidirectional Relationships with acf/save_post

Bidirectional syncing turns relationship fields from fragile editor conventions into deterministic data contracts.

Learning Focus

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.

Core Idea

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

ApproachWhat HappensImpact in Production
Sync both additions and removals during acf/save_postReverse lists always match editor selection on source recordRelated-content widgets are accurate and support tickets drop
Only append reverse links and never removeOld relations remain visible after editor unselects itemsUsers see stale associations and lose trust in content relevance
Use canonical integer arrays with de-duplicationQuery code and exports get stable value shapeFaster debugging and lower integration breakage risk
Guard callback recursion with explicit lockReverse updates do not retrigger endless save chainsStable save behavior with predictable DB writes
Run sync on every post type without gating (wrong pattern)Unrelated saves trigger expensive relation scansHigh write amplification and avoidable performance regressions

Reference Table

Term/APISignature/SyntaxPurposeKey Notes
acf/save_postadd_action('acf/save_post', callable $callback, int $priority = 10, int $accepted_args = 1): voidMain post-save sync hookUse numeric post IDs only; ignore option saves
get_field()`get_field(string $selector, intstring $post_id = false, bool $format_value = true): mixed`Read source and reverse relationship values
update_field()`update_field(string $selector, mixed $value, intstring $post_id = false): intbool`
remove_action()remove_action(string $hook_name, callable $callback, int $priority = 10): boolTemporarily unhook callback during reverse updatesPrevents recursive self-trigger behavior
add_action()add_action(string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1): trueReattach callback after guarded writesKeep priority consistent with removal call
array_diff()array_diff(array $array, array $arrays): arrayCompute added and removed relation IDsCore set math for safe bidirectional sync
wp_is_post_revision()`wp_is_post_revision(intWP_Post $post): intfalse`
wp post meta getwp post meta get <post-id> <meta-key>Inspect raw stored reverse valuesFast operational verification path

Hook Targeting Syntax

VariantSyntax ExampleWhen to Use
Global save hookacf/save_postPrimary place for relationship synchronization
Name-targeted load hookacf/load_value/name=event_speakersOptional preprocessing before comparisons
Key-targeted save variantacf/save_post + field key checks inside callbackUseful when field names may change
Post type gating patternif (get_post_type($post_id) !== 'event') { return; }Mandatory in shared admin environments
ACF Pro Required

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.

  1. Register one acf/save_post callback for event posts.
  2. Read current event_speakers IDs and previously synced IDs from meta snapshot.
  3. Compute added and removed IDs using set difference.
  4. Append event ID to each added speaker reverse list.
  5. Remove event ID from each removed speaker reverse list.
  6. Persist new snapshot for next save cycle.
wp-content/themes/conference-pro/inc/acf/event-speaker-sync.php
<?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);
}
terminal: command
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));'
terminal: output
Array
(
[0] => 2201
)
Array
(
)
note

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.

  1. Gate callback to service posts only.
  2. Normalize service_staff into sorted integer IDs.
  3. Update reverse staff_services lists with de-duplication.
  4. Remove stale links for staff no longer selected.
  5. Save a canonical JSON log payload for observability.
wp-content/themes/clinic-pro/inc/acf/service-staff-sync.php
<?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);
terminal: command
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
terminal: output
Array
(
[0] => 4401
)
{"service_id":4401,"added":[5101,5102],"removed":[],"synced_at":"2026-02-23T18:15:20+00:00"}
warning

Do not store unsorted relationship arrays if you diff or cache these values. Non-deterministic ordering causes false-positive changes.

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)

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

wp-content/themes/clinic-pro/inc/acf/orphan-robust.php
<?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);
terminal: command
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);'
terminal: output
Array
(
[0] => 4401
)
Array
(
[0] => 5109
[1] => 5110
)
warning

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

MistakeRoot CauseWhat Breaks in ProductionCorrect Pattern
Sync callback runs for every save type including options and revisionsNo numeric post ID and revision checksExtra writes and inconsistent relation stateif (!is_numeric($post_id) or wp_is_post_revision((int) $post_id)) { return; }
Only handling newly added related IDsNo previous snapshot for diffingRemoved relations remain visible foreverSave _event_speakers_synced_snapshot and use array_diff() for removals
Missing recursion lock around reverse updatesCallback triggers itself through nested updatesInfinite or noisy save loopsstatic $lock=false; if ($lock) return; $lock=true; update_field('staff_services',$reverseIds,$staffId); $lock=false;
Writing mixed string and integer IDsNo normalization before update_field()Equality checks fail and duplicate IDs appeararray_map('intval', $ids) then array_unique() before save
Storing non-deterministic relation orderNo sorting before persistCache invalidation churn and noisy commitssort($ids); update_field('staff_services', $ids, $staffId);
Ignoring deleted/trashed related postsAssumes ID validity foreverTemplate loops hit missing posts and empty cardsFilter 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

  1. 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.
  2. Keep one snapshot meta key per source relationship and verify with wp post meta get 2201 _event_speakers_synced_snapshot.
  3. 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))));'.
  4. Sort reverse arrays before update_field() so serialized values are stable and diff-friendly.
  5. Add one observability meta log per sync run and inspect with wp post meta get <id> _service_staff_sync_log during QA.
  6. Use revision and autosave guards to avoid syncing temporary editor states.
  7. Run add, remove, and no-op test paths from CLI before release; all three must produce deterministic outputs.

Hands-On Practice

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

CommandPurposeReal Example Output
wp eval '$id=2201; print_r(get_field("event_speakers",$id,false));'Read source relationship IDsArray ( [0] => 3301 [1] => 3302 )
wp eval '$id=3301; print_r(get_field("speaker_events",$id,false));'Read reverse relationship IDsArray ( [0] => 2201 )
wp post meta get 2201 _event_speakers_synced_snapshotInspect previous snapshot for removal diffinga: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 relationdone
wp eval 'echo has_action("acf/save_post") ? "registered" : "missing"; echo PHP_EOL;'Confirm save hook registrationregistered
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_servicesInspect raw reverse meta for one related posta:1:{i:0;i:4401;}
ls wp-content/themes/conference-pro/inc/acf/Verify sync callback file exists in themeevent-speaker-sync.php

What's Next

tip

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.