WordPress
WPML, Polylang, TranslatePress: 19 multisite edge cases
Three previous teams. Three translation plugins. One WordPress multisite. And a content editor at 11pm wondering why the French homepage shows the English title.

It is 23:14 in a Mechelen office and the content lead is staring at the staging French homepage. The title says Welcome to Mechelen. The URL says /fr/. The body is in French. The title field in the translation editor is empty, but the front-end should fall back to French copy, not English. She has filed the same ticket six times this month. The first five times, a developer wrote a SQL one-liner against postmeta and the title reappeared. Then it broke again.
This was week three of consolidating seven WordPress microsites into one multisite for a 24-person Belgian tourism board. Each of the previous three content teams had picked a different translation plugin: WPML on the largest microsite, Polylang on three of the smaller ones, TranslatePress on two. The target stack was a single network on WordPress 6.5 with WPML as the standard, sub-sites mapped to /nl, /fr, /en paths under the main domain.
We wrote the following cheatsheet during the migration. Nineteen edge cases, in five buckets, each tagged with one of three verdicts: Admin (a content editor can fix it in the WordPress UI), Hybrid (the UI lets you try, but a developer audit is wise), or Script (no UI path exists; ship a wp db query against the relationship tables and watch the logs).
URL and routing
1. Same slug in two languages (Polylang)
Verdict: Admin. Polylang stores slugs per language but does not block collisions. Two French and Dutch posts can both end up at /agenda, and the second one quietly takes the URL. An editor can rename one of them. The trap is that 301 redirects from the old microsite point at the wrong language. Fix the slug, then check the Redirection plugin log.
2. wp_unique_post_slug collisions during bulk import
Verdict: Script. When you import 8,000 historical posts at once, wp_unique_post_slug appends -2, -3 to anything that clashes, including its own translation siblings. After import you get museum in Dutch and museum-2 in French. There is no Admin screen that lists this. We ran a CLI scan against wp_posts.post_name grouped by the WPML trid and rewrote the suffixes.
3. Permalink structure differs between locales (TranslatePress)
Verdict: Hybrid. TranslatePress routes through a single canonical URL and switches language via query string or sub-folder. If the previous team had pretty permalinks on Dutch but not on French, the sitemap shows two different patterns and Google indexes neither cleanly. Editors can pick one in Settings. A developer should then clear the redirect cache and resubmit the sitemap.
4. /fr/ prefix missing after multisite domain mapping
Verdict: Script. When you map a sub-site to a sub-folder, WPML stores the language prefix in wp_options.icl_sitepress_settings under the old domain. The Admin UI does not let you rewrite it after the move. We patched the serialized array with a small PHP one-liner and flushed the rewrite rules.
Content and metadata
5. ACF fields not flagged for copy on translation (WPML)
Verdict: Admin. Advanced Custom Fields needs each field group flagged either Copy or Translate in the WPML field control panel. Editors can do this themselves. The catch: if the previous team never set it, the French translations are full of empty fields that nobody noticed because the templates fall back to the Dutch values.
6. Yoast SEO meta drifting between language siblings
Verdict: Hybrid. Yoast stores its meta in postmeta per post, not per trid. So when a marketer updates the Dutch meta description, the French sibling stays on whatever it had three months ago. The Yoast WPML glue plugin partially solves this. Editors can update both copies from the Admin. A developer should still audit the drift before going live.
7. Featured image lost on translation duplicate
Verdict: Admin. WPML duplicates posts but not always the featured image, depending on the Media translation setting. An editor can re-attach it. We made the fix permanent by enabling "copy featured image from original" in the WPML media settings.
8. Reusable blocks (wp_block CPT) not enabled for translation
Verdict: Admin. The wp_block post type is hidden from the translation manager by default. Until you tick it in Settings, the French homepage shows the Dutch CTA block. Editors can enable it. Then they need to retranslate every reused block once.
9. Custom post type registered after WPML save
Verdict: Script. If a theme registers a CPT (say, event) on an action hook that fires after the WPML settings page is rendered, the CPT will not appear in the translation config. The editor sees nothing wrong. We wrote a CLI command that loops over every CPT, asks WPML what its translation mode is, and writes the missing entries directly into icl_sitepress_settings.
10. Block-editor inner HTML containing untranslated strings
Verdict: Script. Gutenberg saves blocks as HTML with inline strings. If a French page was duplicated from Dutch and the editor only changed the visible text in the title field, the aria-label on a button two blocks down still reads Dutch. Screen readers catch this; visual review does not. We regex-scanned post_content for the most common Dutch UI words and flagged 318 pages for review.
Taxonomies and menus
11. Category translation groups orphaned after term merge
Verdict: Script. Polylang stores translation relationships in a hidden taxonomy called term_translations, with the linked group serialized in wp_terms.description. If someone merges two categories from the Admin, the serialized array still references the deleted term. The site keeps working until you try to translate a new post under the merged category, and then the language switcher 404s. The repair is to walk every term_translations row and rewrite the description.
12. Menu items pointing to deleted translation IDs
Verdict: Script. WordPress menus reference posts by ID, not by trid. When you delete a translation and recreate it, the ID changes. The menu still points at the old one. The Admin shows the menu item as valid, because the row in wp_postmeta with _menu_item_object_id still exists. We wrote a CLI script that left-joined menu meta against wp_posts and listed every menu item with a missing target.
13. Tag translations missing the original language entry
Verdict: Admin. Polylang sometimes creates the French tag but forgets to mark the Dutch one as belonging to the same group. Editors can repair this from the Tags screen by clicking the small flag column. The fix takes ten seconds per tag. With 600 tags, plan an afternoon.
SEO and sitemaps
14. Sitemap missing hreflang per language
Verdict: Admin if Yoast WPML glue is installed; Script otherwise. The default WordPress 6.5 sitemap (6.5 release notes) does not know about translation groups. Without the glue plugin, Google indexes the French and Dutch versions as duplicate content. The Admin path is to install the glue and tick one checkbox. The Script path, if you cannot install another plugin, is to filter wp_sitemaps_posts_entry and inject alternate rels yourself.
15. Canonical URL pointing to default language
Verdict: Hybrid. If a post has no translation, WPML can either hide it or fall back to the default language. The fallback mode points the canonical at the Dutch URL even when the visitor lands on /fr/. Editors can change the policy site-wide. Developers should audit which CPTs use it before changing anything.
16. REST API leaking draft translations
Verdict: Script. The wp/v2/posts endpoint returns all language versions of a post, including drafts, unless you filter by lang explicitly. A headless front-end built against the Dutch site can accidentally render unpublished French copy. The fix is a rest_post_query filter that injects the current language. There is no Admin screen for this.
Performance and cron
17. WPML icl_translations table swelling past 500k rows
Verdict: Script. Each post, term, menu item, and string gets a row. After a few years and a few imports, wp_icl_translations stops being indexed well. Editor screens that list 200 posts start taking 8 seconds to load. The repair is to vacuum orphan rows.
# 1. Snapshot the table before touching anything
wp db export icl-snapshot.sql --tables=wp_icl_translations
# 2. List orphan translation rows whose post target no longer exists
wp db query "
SELECT t.translation_id, t.element_id, t.language_code
FROM wp_icl_translations t
LEFT JOIN wp_posts p ON p.ID = t.element_id
WHERE t.element_type LIKE 'post_%' AND p.ID IS NULL
LIMIT 50;
"
# 3. Once verified, delete in batches
wp db query "
DELETE t FROM wp_icl_translations t
LEFT JOIN wp_posts p ON p.ID = t.element_id
WHERE t.element_type LIKE 'post_%' AND p.ID IS NULL
LIMIT 1000;
"18. TranslatePress auto-translate burning DeepL credits per cron run
Verdict: Admin. TranslatePress can be configured to auto-translate any new string it sees. If the same cron-generated event ticker hits the site every 15 minutes with a new timestamp string, the DeepL bill keeps climbing. An editor can disable auto-translate for unknown strings in the Admin. They probably should.
19. Polylang language-switcher cached at edge
Verdict: Hybrid. If your CDN caches the page HTML without varying on the language cookie, a Dutch visitor can land on the cached French version. The Admin fix is to set the language switcher to use distinct URLs per language (sub-folders, not query strings). The developer fix is to add Vary: Cookie on cache rules for the homepage and any landing page that uses the language cookie to redirect.
The script we wrote at midnight
Case 11, the orphaned term_translations group, is the one we ran at 00:40 on a Thursday after staging had been green for six hours. It is also the smallest example of why these consolidations belong in WP-CLI and not in the Admin UI.
<?php
// rebuild-polylang-translations.php
// Run with: wp eval-file rebuild-polylang-translations.php
$languages = ['nl', 'fr', 'en'];
$terms = get_terms([
'taxonomy' => 'post_translations',
'hide_empty' => false,
]);
foreach ($terms as $term) {
$translations = maybe_unserialize($term->description);
if (!is_array($translations)) {
WP_CLI::warning("Skipping {$term->term_id}: not a serialized array");
continue;
}
foreach ($languages as $code) {
if (!isset($translations[$code])) {
$translations[$code] = 0;
}
}
wp_update_term($term->term_id, 'post_translations', [
'description' => maybe_serialize($translations),
]);
WP_CLI::log("Rebuilt term {$term->term_id}");
}
Translation plugins are not interchangeable databases. WPML, Polylang, and TranslatePress each store the relationship between language siblings in a different place. Consolidating them means choosing which place is canon and rewriting the others to match.
What this consolidation actually cost
The board had budgeted four weeks for the multisite move. We spent six. Roughly half of the extra time was Case 9 and Case 11: custom post types that had been registered on a late hook, and term groups that had drifted after a category cleanup nobody remembered doing in 2019. The other half was rerunning the WPML translation memory import after we found that strings registered through String Translation were not being matched against the new sub-site context.
When we ran the consolidation for the tourism board, the thing we kept running into was Case 17: a wp_icl_translations table that had grown past 600,000 rows over six years and was making every Admin list query slow. We ended up solving it by running the snapshot-and-vacuum script above on every sub-site during the cut-over weekend, then adding a monthly cron that flags any orphan growth above 2%. That kind of work is what we mean when we say legacy migration: it is mostly relationship tables and CLI scripts, with a calm content editor on standby.
If you are about to start a similar consolidation, the smallest thing you can do today is run wp db size --tables --format=csv | sort -t, -k2 -n -r | head -20 on each site you plan to merge. The translation-relationship tables that dominate that list are the ones that will set the schedule for the rest of the project.
Key takeaway
A translation plugin migration is not a content migration. It is a relationship-table migration with editorial UI on top, and the wpcli scripts are the real plan.
FAQ
Can we run WPML, Polylang, and TranslatePress side by side during a consolidation?
Only on separate sub-sites of a multisite. They each register conflicting filters on the same hooks. Running two in the same site breaks the language switcher and corrupts translation groups.
Which translation plugin should we standardize on for a multisite?
WPML for large editorial teams that need per-field translation control. Polylang for smaller sites that want lighter overhead. TranslatePress if your team prefers visual front-end editing. The choice is editorial, not technical.
What breaks first when a translation table grows past 500,000 rows?
Admin list screens. The post list and term list both join against the translation table on every load. Orphan rows make MySQL skip the index. Vacuum the orphans monthly and the symptom stops.
Did anything in WordPress 6.5 change how these plugins behave?
The new sitemap behavior and the block-pattern overrides surface a few more edge cases, but the core translation tables of all three plugins still work the way they did in 6.4.
Is there an Admin-only path through all 19 cases?
No. Roughly half of the cases require either a database query or a wpcli script. Plan for a developer pairing session during cut-over, not just editor time.