Skip to content

Proposal: WpmlBlockOverride — runtime source-value override for Copy fields in ACF Gutenberg blocks #29

Description

@parisek

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:

  1. WPML diffs new vs old.
  2. If a translatable string changed (e.g., a Translate-field title), translations are marked needs_update.
  3. With "Translate Everything Automatically" + WPML AI/DeepL, an ATE job is queued automatically.
  4. 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.
  5. 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)

Thread Year Pattern
ACF Gallery Block "copy once" doesn't copy images 2024 Support says change "Copy once" → "Copy" + use ATE. Doesn't help if Copy is already set.
Not all media being copied to translation 2024 Enable all "Duplicate media" options. Helps media library, not post_content drift.
WPML + ACF Blocks + Options Page — fields not detected 2025 Late-2025: block-field detection still actively broken.
Problems with ATE + ACF Gutenberg Blocks 2024 Underscore-prefixed field names = invisible in ATE.
Translate a custom block with ACF attached 2024 Use 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

\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

  1. ✅ Issue opened with consolidated findings (this document)
  2. ⏳ Iterate implementation in atelier99 with real multilingual content
  3. ⏳ Validate against ATE + WPML AI auto-translate flows
  4. ⏳ Open PR back to this repo with the validated WpmlBlockOverride.php
  5. ⏳ atelier99 removes inline copy when Timber 2 migration lands

Will link the PR back here once ready.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions