Problem
WPML propagates fields marked Copy (wpml_cf_preferences = 1) to translations from the save_post path. Any writer that bypasses that path — an importer calling update_field(), a WP-CLI script, a REST endpoint, a migration in wp timber-kit updates — leaves the translations holding whatever they were created with. Nothing warns about it. The source post and its translations simply drift apart and stay that way.
This is the sibling of the problem wp timber-kit acfml-sync-preferences already solves. That command fixes the dictionary (which keys WPML knows about); this proposal is about the values (whether the translations actually match). A site can have a perfectly reconciled dictionary and still serve stale translated meta, because the dictionary only governs what happens on the next sync — it does not repair what already diverged.
Field evidence
Found on a WordPress + WPML site (fellows, en source + cs duplicate per post) with a CPT populated from a Google Sheet importer:
- A flat had
balcony = 0 in the sheet. The English page rendered correctly. The Czech page kept rendering 5 m² — the value the duplicate was created with months earlier.
- The drift was invisible from every direction: the import log reported success, the source post was correct, Site Health said nothing, and the only symptom was a number on one language's front end that nobody was comparing against the other.
- Once the importer was fixed to fire
wpml_sync_all_custom_fields, an ad-hoc sweep found 147 duplicate pairs × 10 fields with zero mismatches. Writing that sweep by hand is what prompted this issue: it is ~30 lines, it is the same 30 lines on every WPML project, and it is the only thing that can tell you whether the site is currently consistent.
WpmlBlockOverride deliberately does not cover this. It is render-time and scoped to ACF blocks in post_content, where a DB write is unsafe because it would clobber the translated content. Plain post meta has no such constraint — there the correct fix is a write, which means the failure mode is stale stored data, and stale stored data is exactly what a health check is for. It also matters more than the block case, because post meta is queried: meta_query, REST, exports and WP-CLI all read the stale value even when a runtime override would have made the page look right.
Proposal
Two pieces, mirroring the acfml-sync-preferences split (pure plan class, unit-tested; thin WP-CLI/Site-Health adapters, not unit-tested).
1. Wpml\CopyFieldDriftPlan — pure comparison logic
Same shape as Acfml\PreferenceSyncPlan: no WordPress, dependencies injected, accumulates a result the callers render.
namespace Parisek\TimberKit\Wpml;
final class CopyFieldDriftPlan {
/**
* @param \Closure(int): array<string, string> $readMeta post id → meta key ⇒ value
* @param \Closure(int): array<string, int> $readTranslations post id → language ⇒ translation id
* @param list<string> $copyKeys WPML's Copy list
*/
public function __construct(
private \Closure $readMeta,
private \Closure $readTranslations,
private array $copyKeys,
) {}
public function inspect( int $sourceId ): void; // accumulate one source post
public function drifted(): array; // [ ['source'=>int,'translation'=>int,'language'=>string,'key'=>string,'source_value'=>string,'translation_value'=>string], … ]
public function pairsChecked(): int;
public function skipped(): array; // posts with no translations, or not in the source language
}
Only keys WPML itself lists as Copy are compared — $sitepress->get_custom_fields_translation_settings( WPML_COPY_CUSTOM_FIELD ). Fields marked Translate are per-language content and a mismatch there is the correct state, not drift. That single decision is what keeps the check from producing hundreds of false positives on any site with real translations, and it is why the comparison cannot be a naive "diff all meta".
2. Site Health check — Health\Check\WpmlCopyFieldDrift
public function id(): string { return 'wpml_copy_field_drift'; }
public function category(): string { return 'timber-kit'; }
public function method(): string { return self::METHOD_EFFECT; }
METHOD_EFFECT is the right classification and worth stating explicitly: the check reads the actual stored values on both sides rather than asking WPML whether syncing is switched on. A site can have every setting correct and still be drifted, which is precisely the case this catches — the interface docblock's "verifies results, not a vendor's checkbox" applied literally.
Results:
Result::good() — every Copy field matches across every translation pair.
Result::recommended() — drift found. Summary names the count and the worst offenders (12 fields across 4 posts, e.g. B.321 [balcony] en="" cs="5"); actions() points at the CLI command below.
Result::recommended() — WPML inactive or no translated post types: the check is not applicable and says so, rather than reporting good for a site it never inspected. Reporting "all clear" for something never checked is the failure mode this project's own doctrine argues against elsewhere.
Cost is the open design question. Site Health runs checks synchronously on page load, and a full sweep is posts × languages × copy_keys meta reads — on the site above that is 147 pairs × 412 Copy keys. Options, roughly in order of preference:
- Sampled sweep — inspect the N most recently modified translated posts (N ≈ 50, filterable). Drift is overwhelmingly caused by a recent programmatic write, so recency is a good proxy. Fast, bounded, and honest as long as the summary states it sampled.
- Cached full sweep — compute in a scheduled event, store the verdict in a transient, have the check read the transient and report its age. Accurate; costs a cron dependency and can serve a stale verdict.
- Single grouped SQL query — join
postmeta for source and translation over icl_translations, filtered to the Copy key list. One query instead of N, but the key list can run to several hundred entries and the query gets unwieldy; probably only viable combined with option 1's bound.
Recommendation: ship option 1, and let the CLI command do the exhaustive pass. The board's job is to notice, not to prove.
3. wp timber-kit wpml-sync-copy-fields — the repair
Same contract as acfml-sync-preferences: exhaustive, dry-run by default, --apply writes.
wp timber-kit wpml-sync-copy-fields # dry-run report
wp timber-kit wpml-sync-copy-fields --apply # push source values to translations
wp timber-kit wpml-sync-copy-fields --post_type=flat # narrow the sweep
wp timber-kit wpml-sync-copy-fields --apply --post=1651 # one post and its translations
Dry-run output is the drift table itself, which makes it reviewable before anything is written:
Checked 147 source posts (147 translation pairs, 412 Copy keys).
B.321 (1651 → 4330 cs) balcony en="" cs="5"
B.320 (1650 → 4329 cs) terrace en="" cs="4.95"
2 fields drifted across 2 posts. Run with --apply to sync.
Repair mechanics, and the constraints that shape them:
- Sync via
do_action( 'wpml_sync_all_custom_fields', $source_id ), not by writing meta directly. WPML's own path routes through wpml_sync_custom_field_copied_value, which is where ACFML remaps attachment IDs and post-object references to their per-language counterparts. Hand-writing the raw value would point a Czech post at an English attachment — a subtler bug than the one being fixed.
- Source direction only. Skip, and report, any post that is not in the source language; syncing from a translation overwrites the original. This is worth an explicit guard rather than an assumption, because a title-based or meta-based lookup elsewhere in a project can easily hand you the translation.
- Never
wpml_make_post_duplicates. That action calls make_duplicate() for every active language regardless of translation mode, and make_duplicate() overwrites post_title, post_content and post_excerpt from the source — data loss on any post someone translated independently. Where duplicates specifically need a full refresh, drive it from the wpml_post_duplicates filter, which returns only translations flagged _icl_lang_duplicate_of.
--apply reports what it changed, per field, so the run is auditable against the dry-run that preceded it.
Why this belongs in timber-kit rather than in each project
The importer that triggered this is project code and its fix stays there. But the class of bug is not project-specific: it is "programmatic meta write on a WPML site", which describes every importer, every updates migration, and every CLI script the kit encourages people to write. The kit already owns both halves of the surrounding machinery — acfml-sync-preferences for the dictionary, WpmlBlockOverride for the block-render case — and this closes the remaining gap between them with the same vocabulary. Re-implementing the sweep per project means every project gets a slightly different definition of "drift", and most get none at all, because you only write it once you have already been bitten.
Scope
In: postmeta on the current site; Copy-marked fields only; Site Health check + CLI command + pure plan class with unit tests.
Out: term meta, options-page meta, user meta, multisite network sweeps (run per-site via wp --url=…, matching acfml-sync-preferences); anything that would auto-repair without --apply; Translate-marked fields.
Open questions
- Site Health cost — which of the three options above, and is a sampled check honest enough for the board?
- Should the check land in a new
wpml category rather than timber-kit, given acfml-sync-preferences and WpmlBlockOverride would arguably move there too?
- Should the CLI command be added to the canonical deploy order (after
acfml-sync-preferences) in dry-run mode, so a deploy surfaces drift without repairing it unattended?
Problem
WPML propagates fields marked Copy (
wpml_cf_preferences = 1) to translations from thesave_postpath. Any writer that bypasses that path — an importer callingupdate_field(), a WP-CLI script, a REST endpoint, a migration inwp timber-kit updates— leaves the translations holding whatever they were created with. Nothing warns about it. The source post and its translations simply drift apart and stay that way.This is the sibling of the problem
wp timber-kit acfml-sync-preferencesalready solves. That command fixes the dictionary (which keys WPML knows about); this proposal is about the values (whether the translations actually match). A site can have a perfectly reconciled dictionary and still serve stale translated meta, because the dictionary only governs what happens on the next sync — it does not repair what already diverged.Field evidence
Found on a WordPress + WPML site (
fellows, en source + cs duplicate per post) with a CPT populated from a Google Sheet importer:balcony = 0in the sheet. The English page rendered correctly. The Czech page kept rendering5 m²— the value the duplicate was created with months earlier.wpml_sync_all_custom_fields, an ad-hoc sweep found 147 duplicate pairs × 10 fields with zero mismatches. Writing that sweep by hand is what prompted this issue: it is ~30 lines, it is the same 30 lines on every WPML project, and it is the only thing that can tell you whether the site is currently consistent.WpmlBlockOverridedeliberately does not cover this. It is render-time and scoped to ACF blocks inpost_content, where a DB write is unsafe because it would clobber the translated content. Plain post meta has no such constraint — there the correct fix is a write, which means the failure mode is stale stored data, and stale stored data is exactly what a health check is for. It also matters more than the block case, because post meta is queried:meta_query, REST, exports and WP-CLI all read the stale value even when a runtime override would have made the page look right.Proposal
Two pieces, mirroring the
acfml-sync-preferencessplit (pure plan class, unit-tested; thin WP-CLI/Site-Health adapters, not unit-tested).1.
Wpml\CopyFieldDriftPlan— pure comparison logicSame shape as
Acfml\PreferenceSyncPlan: no WordPress, dependencies injected, accumulates a result the callers render.Only keys WPML itself lists as Copy are compared —
$sitepress->get_custom_fields_translation_settings( WPML_COPY_CUSTOM_FIELD ). Fields marked Translate are per-language content and a mismatch there is the correct state, not drift. That single decision is what keeps the check from producing hundreds of false positives on any site with real translations, and it is why the comparison cannot be a naive "diff all meta".2. Site Health check —
Health\Check\WpmlCopyFieldDriftMETHOD_EFFECTis the right classification and worth stating explicitly: the check reads the actual stored values on both sides rather than asking WPML whether syncing is switched on. A site can have every setting correct and still be drifted, which is precisely the case this catches — the interface docblock's "verifies results, not a vendor's checkbox" applied literally.Results:
Result::good()— every Copy field matches across every translation pair.Result::recommended()— drift found. Summary names the count and the worst offenders (12 fields across 4 posts, e.g. B.321 [balcony] en="" cs="5");actions()points at the CLI command below.Result::recommended()— WPML inactive or no translated post types: the check is not applicable and says so, rather than reportinggoodfor a site it never inspected. Reporting "all clear" for something never checked is the failure mode this project's own doctrine argues against elsewhere.Cost is the open design question. Site Health runs checks synchronously on page load, and a full sweep is
posts × languages × copy_keysmeta reads — on the site above that is 147 pairs × 412 Copy keys. Options, roughly in order of preference:postmetafor source and translation overicl_translations, filtered to the Copy key list. One query instead of N, but the key list can run to several hundred entries and the query gets unwieldy; probably only viable combined with option 1's bound.Recommendation: ship option 1, and let the CLI command do the exhaustive pass. The board's job is to notice, not to prove.
3.
wp timber-kit wpml-sync-copy-fields— the repairSame contract as
acfml-sync-preferences: exhaustive, dry-run by default,--applywrites.Dry-run output is the drift table itself, which makes it reviewable before anything is written:
Repair mechanics, and the constraints that shape them:
do_action( 'wpml_sync_all_custom_fields', $source_id ), not by writing meta directly. WPML's own path routes throughwpml_sync_custom_field_copied_value, which is where ACFML remaps attachment IDs and post-object references to their per-language counterparts. Hand-writing the raw value would point a Czech post at an English attachment — a subtler bug than the one being fixed.wpml_make_post_duplicates. That action callsmake_duplicate()for every active language regardless of translation mode, andmake_duplicate()overwritespost_title,post_contentandpost_excerptfrom the source — data loss on any post someone translated independently. Where duplicates specifically need a full refresh, drive it from thewpml_post_duplicatesfilter, which returns only translations flagged_icl_lang_duplicate_of.--applyreports what it changed, per field, so the run is auditable against the dry-run that preceded it.Why this belongs in timber-kit rather than in each project
The importer that triggered this is project code and its fix stays there. But the class of bug is not project-specific: it is "programmatic meta write on a WPML site", which describes every importer, every
updatesmigration, and every CLI script the kit encourages people to write. The kit already owns both halves of the surrounding machinery —acfml-sync-preferencesfor the dictionary,WpmlBlockOverridefor the block-render case — and this closes the remaining gap between them with the same vocabulary. Re-implementing the sweep per project means every project gets a slightly different definition of "drift", and most get none at all, because you only write it once you have already been bitten.Scope
In: postmeta on the current site; Copy-marked fields only; Site Health check + CLI command + pure plan class with unit tests.
Out: term meta, options-page meta, user meta, multisite network sweeps (run per-site via
wp --url=…, matchingacfml-sync-preferences); anything that would auto-repair without--apply; Translate-marked fields.Open questions
wpmlcategory rather thantimber-kit, givenacfml-sync-preferencesandWpmlBlockOverridewould arguably move there too?acfml-sync-preferences) in dry-run mode, so a deploy surfaces drift without repairing it unattended?