WpmlBlockOverride — Proposal & Research Findings
Single consolidated reference for everything discovered during research session on 2026-05-28. Linked from atelier99 working session (branch feature/wpml-block-render-override).
TL;DR
Problem: Editor changes an image in an ACF Gutenberg block in the source language. The change never propagates to translation frontends. Forcing sync requires manual "Update translation" via WPML > Translation Management → ATE re-job for every Copy field change. This is documented as a recurring WPML pain point since 2019.
Root cause: WPML/ACFML triggers needs_update only when translatable (text) content changes. A change in a Copy field (wpml_cf_preferences = 1) — typically image/file — is not a translatable change, so the flow never runs. Even if it did, translation post_content is only rewritten during an ATE import job.
Proposed solution: A final class WpmlBlockOverride in parisek/timber-kit that hooks render_block_data at priority 20 (after WPML's own handlers) and, for Copy fields in ACF blocks rendered in a non-default language, overrides attrs.data.<field> with the value from the source-language post — at render time, no DB writes.
Result: ACF configuration becomes the single source of truth for Copy fields. Zero drift. Zero sync mechanism. Zero admin UI. The Translate flow remains entirely handled by ACFML/ATE as today.
1. How it actually works (technical deep-dive)
1.1 Block storage
ACF Gutenberg blocks serialize into post_content as HTML comments:
<!-- wp:acf/jumbotron-video {"id":"block_5fd76xyz","name":"acf/jumbotron-video","data":{"title1":"…","background_image":456,"video":789},"mode":"preview"} /-->
Key observations:
- Every language version of the post has its own
post_content — each with its own serialized block instances and values.
- The
data object holds ACF field values (scalars: text, ID, URL).
- The
id attribute (e.g., block_5fd76xyz) is stable across languages — generated at source insert, inherited by translations. This is our pairing key for source ↔ translation block instances.
1.2 WPML translation flow
When the editor saves a source post:
- WPML diffs new vs old.
- If a translatable string changed (e.g., a Translate-field title), translations are marked
needs_update.
- With "Translate Everything Automatically" + WPML AI/DeepL, an ATE job is queued automatically.
- The ATE job: takes the current source
post_content as a template, fills Translate fields with fresh translations, and for Copy fields uses current source values → regenerates translation post_content.
- Frontend serves the regenerated content.
Where it breaks for Copy fields: step 2 only evaluates translatable strings. Changing a Copy field (image ID) is not a translatable change — no text to retranslate. WPML has no trigger event and the flow never runs. Even manual "Send for translation" can skip the job if "nothing translatable has changed".
1.3 ATE vs Classic editor
| Aspect |
Classic Editor |
ATE |
| UI |
Local WP admin |
WPML cloud webapp |
| Translators |
Manual |
Manual, DeepL, WPML AI, Microsoft |
| Job state |
Local DB |
WPML servers + local |
| Copy sync on import |
Applies preferences (rebuild from source) |
Applies preferences (rebuild from source) |
Important: Both editors respect wpml_cf_preferences during import. The problem isn't the import — it's that the update flow never starts on Copy-only source changes.
1.4 Attachment ID per language
WPML > Settings > Media Translation offers:
- Duplicate media for translations: every attachment gets per-language duplicate IDs (source 123 → SK duplicate 456, same file, language-specific alt text).
- Without duplication: attachment IDs are global across languages.
For our override:
- With duplication: after copying source value
123, remap to lang duplicate via apply_filters('wpml_object_id', 123, 'attachment', true, $target_lang).
- Without duplication: remap is no-op.
2. Existing mechanisms reviewed (and why none fit)
| Mechanism |
What it does |
Why insufficient |
acfml_field_group_mode_field_translation_preference filter (ACFML 2.0.0+) |
Override DEFAULT preference per field in Same/Different fields modes. Returns 0/1/2/3. |
Changes defaults only, not runtime values. |
acfml_should_translate_acf_entity filter (ACFML 2.1.4+) |
Binary gate per field/group. |
Gate, not sync. |
ACFML_REPEATER_SYNC_DEFAULT constant |
Defaults true. Syncs repeater structure across translations. |
Handles repeater structure, not block attribute values. |
wpml-config.xml <gutenberg-blocks> |
Declarative block attribute config. type="post-ids" sub-type="attachment" remaps IDs at render via render_block_data (highest priority). |
Does NOT do source-value sync. Only remaps IDs already in translation post_content (123 → SK_duplicate_of_123), never reaches for source value (456). |
| WPML "Translate Everything Automatically" |
Auto-queues ATE jobs on source change. |
Only triggers on translatable changes. Copy-field changes don't trigger. |
vitaliikaplia/wp-loc plugin (repo) |
Write-time sync (acf/save_post hook, copies shared values to translations). |
Different paradigm. Replaces WPML entirely with its own DB tables and multilingual stack. Unusable in WPML+ACFML projects. |
acf-extended/ACF-Extended (repo) |
WPML compatibility for ACF Extended modules. |
Orthogonal — doesn't address block Copy-field sync. |
| Manual "Update translation" in WPML UI |
Re-queues ATE job, regenerates post_content. |
Manual step per change. Editors forget. |
Why our approach is novel and complementary
We hook the same core filter (render_block_data) as WPML — at a later priority — and solve an orthogonal problem: "source value is single source of truth, ignore stored value in translation post_content for Copy fields". The Translate flow remains entirely handled by ACFML/ATE.
3. WPML forum threads (years of recurrence)
Conclusion: Problem is persistent, documented, unsolved in WPML core since ACFML 1.3 (May 2019). No official fix planned — WPML's model assumes each language version is an independent document.
4. Proposed solution
4.1 Public API
\Parisek\TimberKit\WpmlBlockOverride::register();
Filters exposed:
timber_kit/wpml_block_override/should_override — (bool, $block, $current_lang, $default_lang)
timber_kit/wpml_block_override/copy_fields — (array $copy_fields, string $block_name)
4.2 Architecture
Single final class WpmlBlockOverride (matches kit's BlockRenderer.php pattern):
declare(strict_types=1), PHP 8.3+, tab indent, kit code style
- Static methods (no DI ceremony)
- Class constants for cache keys and hook priority
- Filter
timber_kit/<feature>/<event> naming
- No Timber dependency — works on Timber 1 and 2 identically
4.3 Data flow
do_action('render_block_data', $block, $source_block)
↓
WpmlBlockOverride::filter(...)
↓ shouldOverride() — bypass if non-acf/admin/REST/default-lang
↓ getCopyFields(block_name) — cached field→preference map
↓ getSourcePostId() — apply_filters('wpml_object_id', ..., default_lang)
↓ getSourceBlocks(source_post_id) — parse_blocks, memoized
↓ findSourceBlock(translated, sources) — match by attrs.id
↓ applyCopyFields() — overwrite attrs.data.<field> + remap attachment IDs
↓
return modified block to WP render pipeline
4.4 Hook priority decision
Priority 20 (not the default 10) so we run after WPML's own render_block_data handlers (documented as "highest priority"). Ensures our overrides aren't reverted.
4.5 Defensive details
- Skip fields with names starting with
_ (WPML system-field convention)
- Per-request memoization for source-post block parses
- Transient cache (24h) for ACF field-group lookups, invalidated on
acf/update_field_group
- Bypass in
is_admin() and REST_REQUEST contexts (editor preview safety)
findSourceBlock returning null = safe degrade (use translation value, log warning under WP_DEBUG)
5. Identified weak spots & mitigations
| Issue |
Status |
Mitigation |
| Hook priority — default 10 might run before WPML |
✅ Fixed |
Priority 20 |
| Underscore field names — WPML ignores |
✅ Fixed |
str_starts_with($name, '_') skip |
| Nested fields in Repeater/Group/Flexible — invisible to top-level scan |
✅ Out-of-MVP |
Non-goal documented. Atelier99 doesn't use nested Copy in Translate containers today. |
| REST API output — returns raw post_content |
✅ Out-of-MVP |
Server-rendered project. Future: rest_prepare_* filter. |
Editor preview — render_block_data runs in admin |
✅ Fixed |
is_admin() bypass |
| Structural drift (translation has different block count) |
✅ Fixed |
findSourceBlock returns null, safe degrade |
| Mid-request source change invalidation |
⚠ Open |
$sourceBlocksMemo is per-request — extremely rare edge case |
"Copy once" (wpml_cf_preferences = 3) |
✅ Documented |
Only override === 1 (Copy). Copy once = "transfer once then ignore source" — we do nothing |
| WPML Same vs Different fields mode |
⚠ Untested |
Spec assumes Same fields. Different fields has divergent field structure across langs — findSourceBlock could fail. To verify during implementation. |
Blocks without attrs.id (pre-ACF v3) |
✅ Documented |
Returns null, safe degrade |
6. Relevant hooks reference
WordPress core
render_block_data — modify block pre-render
render_block — modify final HTML
parse_blocks(), serialize_blocks() — block tree I/O
WPML
apply_filters('wpml_current_language', null) — current lang code
apply_filters('wpml_default_language', null) — default lang
apply_filters('wpml_object_id', $id, $type, $return_original_if_missing, $lang) — language conversion
apply_filters('wpml_post_to_translation_package', null, $post_id) — extract translation package
do_action('wpml_tm_send_post_jobs', $jobs) — enqueue ATE job
ICL_SITEPRESS_VERSION — defined() check
ACF / ACFML
acf_get_field_groups(['block' => 'acf/name']), acf_get_fields($group)
acf/load_value, acf/format_value, acf/update_field_group
acfml_field_group_mode_field_translation_preference (2.0.0+)
acfml_should_translate_acf_entity (2.1.4+)
7. Decision log
| Decision |
Rationale |
| Read-time override > write-time sync |
Safer (no DB writes), no WPML state conflict, simpler |
| Single class > 4-class DI |
Match kit's BlockRenderer.php pattern. Less ceremony. |
Namespace Parisek\TimberKit inline |
Zero-touch migration to kit — just move the file |
| Hook priority 20 > 10 |
Run after WPML's own render_block_data handlers |
Skip _underscore fields |
WPML convention — system fields, invalid target |
| Nested Repeater/Group/Flexible = out-of-MVP |
Atelier99 doesn't need it today. YAGNI. |
remapAttachmentId in filter > delegate to wpml-config.xml |
Fewer moving parts, no XML drift |
| No admin UI (notice/dashboard/bulk) |
Read-time override eliminates drift = nothing to show |
| Cross-reference issue > immediate PR |
Lighter commitment, design discussion first |
| Develop inline in atelier99 first |
Real multilingual content for testing; atelier99 still on Timber 1 |
8. Open questions for kit adoption
- Naming:
WpmlBlockOverride vs BlockTranslationOverride vs WpmlCopyFieldOverride? Current pick: WpmlBlockOverride — explicit about WPML dependency.
- Scope: should the kit version also handle nested fields in Repeater/Group/Flexible? MVP in atelier99 is top-level only.
- Declarative alternative: should the kit ship both the runtime filter AND a generator for
wpml-config.xml from ACF field group config?
- Bootstrap pattern: kit's other features (e.g.,
BlockRenderer) register via static call in theme functions.php. Same pattern here, or auto-bootstrap?
- Field type coverage: image/file/gallery covered. Should we extend to ACF post_object, relationship, taxonomy when marked Copy?
9. Reference implementation
Currently being developed inline in atelier99 theme (Timber 1, can't yet require this kit):
atelier99/wp-content/themes/atelier99/classes/Parisek/TimberKit/WpmlBlockOverride.php
Branch: feature/wpml-block-render-override
Spec file path: docs/superpowers/specs/2026-05-28-wpml-block-render-override-design.md
When atelier99 migrates to Timber 2 → adopt timber-kit version, delete inline copy in the same commit (to avoid classmap/PSR-4 collision).
10. Sources
WPML official:
WPML forum (problem-validation threads):
ACF official:
Other plugins / repos:
11. Next steps
- ✅ Issue opened with consolidated findings (this document)
- ⏳ Iterate implementation in atelier99 with real multilingual content
- ⏳ Validate against ATE + WPML AI auto-translate flows
- ⏳ Open PR back to this repo with the validated
WpmlBlockOverride.php
- ⏳ atelier99 removes inline copy when Timber 2 migration lands
Will link the PR back here once ready.
WpmlBlockOverride — Proposal & Research Findings
TL;DR
Problem: Editor changes an image in an ACF Gutenberg block in the source language. The change never propagates to translation frontends. Forcing sync requires manual "Update translation" via WPML > Translation Management → ATE re-job for every Copy field change. This is documented as a recurring WPML pain point since 2019.
Root cause: WPML/ACFML triggers
needs_updateonly when translatable (text) content changes. A change in a Copy field (wpml_cf_preferences = 1) — typically image/file — is not a translatable change, so the flow never runs. Even if it did, translationpost_contentis only rewritten during an ATE import job.Proposed solution: A
final class WpmlBlockOverrideinparisek/timber-kitthat hooksrender_block_dataat priority 20 (after WPML's own handlers) and, for Copy fields in ACF blocks rendered in a non-default language, overridesattrs.data.<field>with the value from the source-language post — at render time, no DB writes.Result: ACF configuration becomes the single source of truth for Copy fields. Zero drift. Zero sync mechanism. Zero admin UI. The Translate flow remains entirely handled by ACFML/ATE as today.
1. How it actually works (technical deep-dive)
1.1 Block storage
ACF Gutenberg blocks serialize into
post_contentas HTML comments:<!-- wp:acf/jumbotron-video {"id":"block_5fd76xyz","name":"acf/jumbotron-video","data":{"title1":"…","background_image":456,"video":789},"mode":"preview"} /-->Key observations:
post_content— each with its own serialized block instances and values.dataobject holds ACF field values (scalars: text, ID, URL).idattribute (e.g.,block_5fd76xyz) is stable across languages — generated at source insert, inherited by translations. This is our pairing key for source ↔ translation block instances.1.2 WPML translation flow
When the editor saves a source post:
needs_update.post_contentas a template, fills Translate fields with fresh translations, and for Copy fields uses current source values → regenerates translationpost_content.Where it breaks for Copy fields: step 2 only evaluates translatable strings. Changing a Copy field (image ID) is not a translatable change — no text to retranslate. WPML has no trigger event and the flow never runs. Even manual "Send for translation" can skip the job if "nothing translatable has changed".
1.3 ATE vs Classic editor
Important: Both editors respect
wpml_cf_preferencesduring import. The problem isn't the import — it's that the update flow never starts on Copy-only source changes.1.4 Attachment ID per language
WPML > Settings > Media Translation offers:
For our override:
123, remap to lang duplicate viaapply_filters('wpml_object_id', 123, 'attachment', true, $target_lang).2. Existing mechanisms reviewed (and why none fit)
acfml_field_group_mode_field_translation_preferencefilter (ACFML 2.0.0+)acfml_should_translate_acf_entityfilter (ACFML 2.1.4+)ACFML_REPEATER_SYNC_DEFAULTconstanttrue. Syncs repeater structure across translations.wpml-config.xml<gutenberg-blocks>type="post-ids" sub-type="attachment"remaps IDs at render viarender_block_data(highest priority).vitaliikaplia/wp-locplugin (repo)acf/save_posthook, copies shared values to translations).acf-extended/ACF-Extended(repo)Why our approach is novel and complementary
We hook the same core filter (
render_block_data) as WPML — at a later priority — and solve an orthogonal problem: "source value is single source of truth, ignore stored value in translation post_content for Copy fields". The Translate flow remains entirely handled by ACFML/ATE.3. WPML forum threads (years of recurrence)
wpml-config.xml. Solves string translation, not source sync.Conclusion: Problem is persistent, documented, unsolved in WPML core since ACFML 1.3 (May 2019). No official fix planned — WPML's model assumes each language version is an independent document.
4. Proposed solution
4.1 Public API
Filters exposed:
timber_kit/wpml_block_override/should_override—(bool, $block, $current_lang, $default_lang)timber_kit/wpml_block_override/copy_fields—(array $copy_fields, string $block_name)4.2 Architecture
Single
final class WpmlBlockOverride(matches kit'sBlockRenderer.phppattern):declare(strict_types=1), PHP 8.3+, tab indent, kit code styletimber_kit/<feature>/<event>naming4.3 Data flow
4.4 Hook priority decision
Priority 20 (not the default 10) so we run after WPML's own
render_block_datahandlers (documented as "highest priority"). Ensures our overrides aren't reverted.4.5 Defensive details
_(WPML system-field convention)acf/update_field_groupis_admin()andREST_REQUESTcontexts (editor preview safety)findSourceBlockreturningnull= safe degrade (use translation value, log warning under WP_DEBUG)5. Identified weak spots & mitigations
str_starts_with($name, '_')skiprest_prepare_*filter.render_block_dataruns in adminis_admin()bypassfindSourceBlockreturns null, safe degrade$sourceBlocksMemois per-request — extremely rare edge casewpml_cf_preferences = 3)=== 1(Copy). Copy once = "transfer once then ignore source" — we do nothingfindSourceBlockcould fail. To verify during implementation.attrs.id(pre-ACF v3)6. Relevant hooks reference
WordPress core
render_block_data— modify block pre-renderrender_block— modify final HTMLparse_blocks(),serialize_blocks()— block tree I/OWPML
apply_filters('wpml_current_language', null)— current lang codeapply_filters('wpml_default_language', null)— default langapply_filters('wpml_object_id', $id, $type, $return_original_if_missing, $lang)— language conversionapply_filters('wpml_post_to_translation_package', null, $post_id)— extract translation packagedo_action('wpml_tm_send_post_jobs', $jobs)— enqueue ATE jobICL_SITEPRESS_VERSION— defined() checkACF / ACFML
acf_get_field_groups(['block' => 'acf/name']),acf_get_fields($group)acf/load_value,acf/format_value,acf/update_field_groupacfml_field_group_mode_field_translation_preference(2.0.0+)acfml_should_translate_acf_entity(2.1.4+)7. Decision log
BlockRenderer.phppattern. Less ceremony.Parisek\TimberKitinlinerender_block_datahandlers_underscorefieldsremapAttachmentIdin filter > delegate towpml-config.xml8. Open questions for kit adoption
WpmlBlockOverridevsBlockTranslationOverridevsWpmlCopyFieldOverride? Current pick:WpmlBlockOverride— explicit about WPML dependency.wpml-config.xmlfrom ACF field group config?BlockRenderer) register via static call in themefunctions.php. Same pattern here, or auto-bootstrap?9. Reference implementation
Currently being developed inline in
atelier99theme (Timber 1, can't yet require this kit):Branch:
feature/wpml-block-render-overrideSpec file path:
docs/superpowers/specs/2026-05-28-wpml-block-render-override-design.mdWhen atelier99 migrates to Timber 2 → adopt timber-kit version, delete inline copy in the same commit (to avoid classmap/PSR-4 collision).
10. Sources
WPML official:
WPML forum (problem-validation threads):
ACF official:
Other plugins / repos:
11. Next steps
WpmlBlockOverride.phpWill link the PR back here once ready.