This article covers the meta synchronization pipeline in thewpresidence-translate plugin (text domain wpr-translate): how rules are loaded, how they are normalized, which hooks drive the synchronization, and what safeguards prevent infinite loops. It is the companion to the user-facing Custom Field Rules article and feeds into the wider multi-language real estate website architecture.
Files Involved
| File | Role |
|---|---|
includes/meta-sync.php |
Runtime synchronization on save_post and meta change hooks. |
includes/custom-field-rules.php |
Rule loader, override storage, reload orchestration, state reporting. |
includes/custom-field-rules-storage.php |
Normalization, grouping, and legacy rule conversion. |
includes/custom-fields-sync.php |
JSON config path resolution, hash tracking, preferences cache. |
includes/admin/views/settings-custom-fields.php |
Admin UI view rendering the Custom Field Rules page. |
Behavior Keywords
Four keywords are accepted throughout the subsystem. They are normalized in wpr_translate_normalize_meta_field_behavior_value():
copy // propagate on every save_post of any sibling
copy-once // propagate only when a translation is first created
translate // never propagated; language-specific value expected
ignore // never touched
The legacy alias copy_once is rewritten to copy-once. Unknown values are dropped.
Rule Source of Truth
Defaults come from a JSON file in the active theme:
get_stylesheet_directory() . '/wpr/custom-fields-config.json'
// falls back to
get_template_directory() . '/wpr/custom-fields-config.json'
The resolver is wpr_translate_get_custom_fields_config_path() in custom-fields-sync.php. The JSON is read by wpr_translate_read_custom_field_rules_file() in custom-field-rules.php, which accepts either a top-level array or an object with a custom_fields key. Each rule can carry:
{
"id": "property_price",
"label": "Price",
"description": "Property asking price",
"action": "copy",
"post": "estate_property"
}
The newer value key is accepted as an alias for action.
Normalization & Grouping
wpr_translate_normalize_custom_field_rules() sanitizes every entry, generates a unique uid per post type via wpr_translate_build_unique_rule_id(), and defaults the action to copy when missing. wpr_translate_group_custom_field_rules_by_post() groups rules by post type and pushes estate_property to the top of the result array so the admin UI renders Property fields first.
Options Used
| Option | Contents |
|---|---|
wpr_cf_rules_defaults |
Normalized default rules + file hash, path, and last-loaded timestamp. |
wpr_cf_rule_overrides |
Admin-changed behaviors keyed by rule uid. |
wpr_cf_file_state |
Status/error payload for the JSON loader surfaced on the admin page. |
wpr_cf_preferences |
Secondary cache maintained by custom-fields-sync.php (hash + raw rules). |
Overrides are merged on top of defaults in wpr_translate_get_custom_field_rules(). Each rule in the merged set carries a source of file or override, and the original file_action is preserved for UI display.
Runtime Hooks
Two callbacks drive synchronization, both in meta-sync.php:
| Hook | Callback | Purpose |
|---|---|---|
save_post |
wpr_translate_handle_save_post_meta_sync() |
Full-sweep sync of every copy meta key when any post in the translation set is saved. |
updated_postmeta / added_postmeta / deleted_postmeta |
wpr_translate_handle_post_meta_change_sync() |
Single-key sync for code paths that write meta directly without calling wp_update_post(). |
Both callbacks short-circuit on autosaves, revisions, auto-draft posts, and non-existent post types. They also respect the opt-out filter:
apply_filters( 'wpr_translate_should_sync_post_meta', true, $post_id, $post_type, $post );
Sibling Resolution
Translation siblings are resolved by wpr_translate_resolve_meta_sync_translation_post_ids() using a two-tier strategy:
- Meta-first: reads
wpr_translated_original_post_idfrom the current post to find the canonical anchor, then runs aget_posts()query withsuppress_filters = truefor every post pointing at the same anchor. - TRID fallback: if meta linkage yielded only the current post, the resolver calls
wpr_translation_get_translation_group()with the element type fromwpr_translation_get_element_type( $post_type ).
The suppress_filters flag is critical — the sibling lookup must not be language-filtered by query-filter.php, or it would never see posts in other languages.
Full-Sweep Sync: wpr_sync_meta_fields()
Called from the save_post entry point. Pseudocode:
$behaviors = wpr_translate_get_meta_field_behaviors();
$copy_keys = keys where behavior === 'copy';
$siblings = wpr_translate_resolve_meta_sync_translation_post_ids( $post_id, $post_type );
foreach ( $copy_keys as $meta_key ) {
$source_values = get_post_meta( $source_post_id, $meta_key, false );
foreach ( $siblings as $target ) {
if ( serialized($source) !== serialized($target_current) ) {
wpr_translate_meta_sync_enter_internal_write();
delete_post_meta( $target, $meta_key );
foreach ( $source_values as $v ) add_post_meta( $target, $meta_key, $v );
wpr_translate_meta_sync_exit_internal_write();
}
}
}
Values are compared through maybe_serialize( maybe_unserialize( $v ) ) to avoid redundant rewrites when the shape changes but the semantic value does not.
Re-Entry Guards
- Per-request static guard keyed by
post_type:post_idinsidewpr_sync_meta_fields(). Prevents a sibling save from recursing back to the source. - Internal-write depth counter via
wpr_translate_meta_sync_enter_internal_write()/exit_internal_write(). The single-key handler checkswpr_translate_meta_sync_is_internal_write()and bails out when the change event was triggered by our own write. - Availability check via
wpr_translation_is_translation_post_available()— trashed or unpublished translations are skipped.
Extension Points
apply_filters( 'wpr_translate_should_sync_post_meta', $should, $post_id, $post_type, $post )— veto sync per post or post type.apply_filters( 'wpr_translate_meta_field_behaviors', $map )— final chance to mutate the behavior map (add/remove keys) before sync runs.apply_filters( 'wpr_translate_custom_field_rules_file', $path )— override the JSON defaults file path, e.g. for a plugin shipping its own defaults.
Admin Page Wiring
The view at includes/admin/views/settings-custom-fields.php receives $data['custom_field_rules'] (the merged map) and $data['custom_field_state'] (reload status). The reload button posts back with wpr_cf_action=reload and nonce action wpr_cf_reload; the handler calls wpr_translate_reload_custom_field_rules_from_file() which resets wpr_cf_rule_overrides, persists new defaults, and updates wpr_cf_file_state.
AJAX dropdown changes are persisted via wpr_translate_save_custom_field_overrides(), which sanitizes keys through sanitize_text_field() (UTF-8 safe) and validates actions against the copy|translate|copy-once|ignore allowlist.
Legacy Import
wpr_translate_convert_legacy_custom_fields_to_rules() in custom-field-rules-storage.php accepts the WPResidence wp_estate_custom_fields option format (indexed arrays with [0]=name, [1]=label, [2]=type, [4]=choices) and emits normalized rules. This is merged in on reload so older installations keep expected defaults for their theme-defined custom fields.
Related Reading
- Translation Linking (trid system) — background on how sibling posts are glued together.
- WP_Query Language Filtering — explains why sibling queries must use
suppress_filters. - Database Schema — the translation table consulted by the TRID fallback path.
For product context, visit the multi-language real estate website landing page.