wp-polylang · diff
git:20260827.dde8cda to git:20260902.b69fc03
65 added, 0 removed. Audit A to A.
---
name: wp-polylang
description: Polylang multilingual methodology — one post per language joined by translation groups, driven through the pll_* API
user-invocable: false
---
# Polylang Multilingual System
This skill documents how to drive Polylang correctly from automation. It is the
alternative to `wp-bilingual`, which documents the ACF `_suffix` pattern. The two
are mutually exclusive per project: `_suffix` keeps one post with `hero_title`
and `hero_title_es`; Polylang keeps one post per language, each with the same
unsuffixed fields, joined into a translation group.
`wp-bilingual` remains the default. Choosing Polylang does not deprecate it.
## Data model
Polylang stores two things per translatable object:
| What | Where |
|---|---|
| The object's language | the `language` taxonomy (`term_language` for terms) |
| Which objects are translations of each other | the `post_translations` taxonomy (`term_translations` for terms) |
A translation group is a single term whose description holds a serialised map of
`lang => object_id`. Both taxonomies must agree. **Writing either one directly is
the mistake this document exists to prevent** — a group written by hand is
routinely asymmetric, and Polylang then reports the page as untranslated while
the database looks correct.
Always go through the API:
| Task | Call |
|---|---|
| Set a post's language | `pll_set_post_language( $post_id, $lang )` |
| Join posts as translations | `pll_save_post_translations( [ 'es' => 12, 'en' => 34 ] )` |
| Read a post's group | `pll_get_post_translations( $post_id )` |
| Set a term's language | `pll_set_term_language( $term_id, $lang )` |
| Join terms as translations | `pll_save_term_translations( [ 'es' => 5, 'en' => 9 ] )` |
| List configured languages | `pll_languages_list()` |
| Default language | `pll_default_language()` |
`pll_save_post_translations()` **replaces** the whole group rather than merging
into it — it is not a delta call. Passing only `{source, target}` silently drops
every other language already in that group, including languages the array
never mentions. The correct pattern is to read the existing group first with
`pll_get_post_translations()`, merge the source and target ids into it, and
save the merged result:
```php
$group = pll_get_post_translations( $source_id );
$group[ $source ] = $source_id;
$group[ $target ] = $target_id;
pll_save_post_translations( $group );
```
Same shape for terms with `pll_get_term_translations()` /
`pll_save_term_translations()`. `create_media_translation()` is the one
exception — it merges internally, so it does not need this pattern.
## Running PHP against a site
No `wp pll` command is available, so automation runs through `wp eval-file`:
```bash
wp eval-file script.php es en
```
- Positional arguments arrive as `$args`. `--flags` are **not** available; WP-CLI
consumes those itself.
- The file is evaluated as real PHP source, so quoting is not a hazard the way it
is with `wp eval "..."`.
- `__DIR__` resolves to the script's own directory despite the `eval()` wrapper,
so scripts can `require_once __DIR__ . '/pll-lib.php'`.
## Menus
Per-language menu assignment lives in the `polylang` option, not in theme mods:
```php
$options = get_option( 'polylang' );
$options['nav_menus'][ $theme_slug ][ $location ][ $lang ] = $menu_term_id;
update_option( 'polylang', $options );
```
`combine_location()` (public instance method on `PLL_Nav_Menu`, inherited by
`PLL_Admin_Nav_Menu`) composes the synthetic `location___lang` key the admin UI
and the frontend use. It is a naming helper, not a storage API — Polylang reads
the option path above directly at `src/admin/admin-nav-menu.php:279`.
A translated menu whose items still point at source-language objects is the most
common Polylang misconfiguration, and it is invisible until a visitor clicks and
lands in the wrong language. Re-point every item with
`pll_get_post_translations()`.
A `custom` menu item (a literal href, not an object id + type) is not
automatically safe just because it is not `post_type` or `taxonomy`: a
duplicated menu produces exactly this shape, and a `custom` item whose URL
happens to be one of the site's own permalinks needs re-pointing the same
way — see "Internal links inside translated content" below, which covers
this case too.
## Internal links inside translated content
A source post's content, or an ACF reference field, may link to another
source-language post by its own permalink. That href is copied verbatim into
the translated counterpart along with the rest of the content — nothing
parses it — so after import it still points at the SOURCE-language post: a
button on the English page sends the visitor back to the Spanish site. This
is the same defect as an untranslated menu item, in post content instead of
a menu.
`pll-import.php` closes this with a link-rewrite pass that runs **after
every post's counterpart exists**, for the same reason the parent-fixup pass
does: a link's target may gain its own counterpart later than the post
containing the link, on a run where the linking post itself was skipped by
hash. The pass therefore runs over **every target-language post with a
source-language counterpart**, not only the posts (re)written in the current
run — cheap and idempotent, so this is safe to do unconditionally on every
import, including a real site's already-translated pages. It applies to:
- same-host `href="..."` attributes inside `post_content`;
- ACF/SCF `link` (its `url` key — the `title` is translatable text and
travels through the manifest instead), `page_link`, `post_object` and
`relationship` fields (read from the SOURCE post every run, since these
types are never part of the translatable payload — see below — so nothing
else ever gives the target a value to begin with);
- `custom` menu items, whenever their URL resolves to a post.
Rules:
- **Same host only.** Compared by *host*, not by a `home_url()` string
prefix — `url_to_postid()` itself tolerates a scheme mismatch (measured:
an `https://` href against an `http://` site still resolved), and a
literal prefix test would not. A different host is never touched.
- **Resolved with `url_to_postid()`.** A zero result means it is not a post
URL (an archive, a term, the home page) and is left exactly as it is.
Measured on this test site: a WooCommerce shop page's own permalink does
**not** resolve through `url_to_postid()` even with the correct host —
this is a real, observed limitation of WordPress's own resolver, not a
bug in this pass, and such links are silently left alone like any other
non-post URL.
- **Re-pointed via `pll_get_post( $id, $target_lang )`.** If it returns
nothing, the target has no counterpart yet: the link is left pointed at
the source and `pllx_warn()` names both posts. A link into the other
language is bad; a broken link is worse.
- **Query string and fragment are preserved**, and a root-relative href is
written back root-relative (`/servicios/?x=1#contacto` keeps both parts).
- Idempotent: every candidate rewrite is compared against the current value
first, so a second run over unchanged content writes nothing and
`post_content` stays byte-identical.
`pll-verify.php`'s check 9 audits the same condition on `post_content` as a
**hard failure** — the same severity as its menu-item check, for the same
reason — and check 1 (menus) now also inspects `custom` items whose URL
resolves to a post.
+ ## An object with no language does not exist
+
+ Polylang filters every front-end query by the current language, and an object
+ carrying no `language` term matches none of them. A post, a page, a menu, a
+ media item or a **taxonomy term** created without `pll_set_post_language()` /
+ `pll_set_term_language()` is present in the database, visible in the admin, and
+ absent from the site. There is no warning. Assign a language in the same step
+ that creates the object, and sweep afterwards:
+
+ Sweep both halves. `post_type => "any"` silently skips attachments — they are
+ `exclude_from_search` — so the types are listed and filtered instead, and terms
+ need their own pass because no post query ever reaches them:
+
+ ```bash
+ # Posts, pages, menu items and media.
+ $WP eval 'foreach (get_post_types() as $pt) { if (!pll_is_translated_post_type($pt)) continue; foreach (get_posts(["post_type"=>$pt,"numberposts"=>-1,"post_status"=>"any"]) as $p) { if (!pll_get_post_language($p->ID)) echo "NO LANG: {$pt} {$p->ID} {$p->post_title}\n"; } }'
+
+ # Taxonomy terms.
+ $WP eval 'foreach (get_taxonomies() as $tax) { if (!pll_is_translated_taxonomy($tax)) continue; foreach (get_terms(["taxonomy"=>$tax,"hide_empty"=>false]) as $t) { if (!pll_get_term_language($t->term_id)) echo "NO LANG: {$tax} {$t->term_id} {$t->name}\n"; } }'
+ ```
+
+ Both must print nothing. The `pll_is_translated_*` guards keep the sweep quiet
+ about types and taxonomies Polylang was never asked to translate, which have no
+ language by design.
+
+ ## Do not translate a taxonomy of proper nouns
+
+ Registering a taxonomy with `pll_get_taxonomies` looks free and is not. Where
+ the terms are proper nouns — provinces, countries, brands, venue names — most
+ of them are spelled identically in both languages, so translating the taxonomy
+ duplicates every term in order to relabel the one or two that differ. Three
+ things then go wrong:
+
+ 1. **Slugs.** WordPress forces term slugs to be unique per taxonomy, so the
+ translated copies can only be `matanzas-en`, `holguin-en`. An archive facet
+ writes that slug straight into a URL the visitor shares.
+ 2. **Import collision.** Because the "translated" name matches its source, an
+ importer adopts the *existing* term as its own counterpart and flips its
+ language — one project lost the province facet from every Spanish archive
+ this way, with 14 terms silently reassigned from `es` to `en`.
+ 3. **Counts.** A shared taxonomy's `get_terms()` count spans every object type
+ registered to it; per-post-type facet counts must be recomputed.
+
+ Leave such a taxonomy **out** of `pll_get_taxonomies` so both languages share
+ one clean term list, and write the reason into `inc/post-types.php` — the next
+ person will otherwise "fix" the omission.
+
+ ## Labels registered in PHP are not translatable strings
+
+ `prefix_t()` covers the strings the templates print, but WordPress builds some
+ output from the labels passed to `register_post_type()`, which are plain
+ literals with no gettext behind them. `post_type_archive_title()` — the CPT
+ archive's `<title>` — is the one that bites: an English visitor gets the
+ Spanish plural over an English page. Filter it against a registered string:
+
+ ```php
+ add_filter( 'post_type_archive_title', function ( $title, $post_type ) {
+ $key = 'plural_' . $post_type;
+ return prefix_t( $key ) ?: $title;
+ }, 10, 2 );
+ ```
+
+ Audit `wp_title`/`document_title_parts`, `the_archive_title`, and any admin
+ label a client will see, the same way.
+
## What free Polylang covers
Verified on Polylang 3.8.7 with no paid addon: `product` is translatable as a
post type, and `product_cat`, `product_tag`, `product_brand` and the `pa_*`
attribute taxonomies are all translatable. Product *content* needs no addon.
What does need the paid Polylang for WooCommerce addon is the runtime plumbing —
per-language cart, checkout and account page mapping, product variations, WC
emails. That is not content and is out of scope for content translation.
## ACF / SCF custom fields
`pllx_acf_payload()` in `pll-lib.php` flattens a post's custom-field values to a
dot-notation map (`pllx_acf_walk()`); `pllx_acf_write()` in `pll-import.php`
writes that map back through `update_field()`/`get_field()`. Both work against
whatever plugin defines `get_field_objects()`, `get_field()` and
`update_field()` — that is ACF or SCF, never both (see below).
**Translated** (the value is walked, sent through translation, written back):
| Field type | Key shape |
|---|---|
| `text`, `textarea`, `wysiwyg` | `name` |
| `group` (one level) | `group_name.sub_name` |
| `repeater` (one level) | `repeater_name.ROW_INDEX.sub_name` |
| `flexible_content` (one level) | `flex_name.ROW_INDEX.sub_name` |
| `link` (title only) | `link_name.title` |
A `flexible_content` row's own `acf_fc_layout` tag is never emitted as a
translatable key — it is a machine identifier, not text — but the importer
still needs it to write a valid row. A row the target already has keeps its
existing tag untouched by the read-modify-write; a row being created for the
first time (a brand-new translation counterpart) gets it backfilled from the
corresponding row on the *source* post, since that's the only other place
that still identifies the row's layout. Without this, a fresh flexible-content
row written through the same dot-notation path as a repeater row is invalid
and SCF/ACF silently drops the whole field — this was measured, not assumed
(see `tests/checks/wp-polylang-live.sh` and the Task 8 report in
`.superpowers/sdd/2026-08-21-wp-polylang-retrofit/`).
**Copied verbatim, never translated** (present in the field group, absent
from the dot-notation map, untouched by the importer): `image`, `number`,
`true_false`, `url`, and any other type not listed above.
**Re-pointed, not translated** (never walked into the manifest at all; fixed
up directly on the target post by the link-rewrite pass in `pll-import.php`
— see "Internal links inside translated content" above): `link`'s `url` key,
`page_link` (a permalink string, not an id — measured; see below), and
`post_object` / `relationship` (ids, given `return_format => 'id'` — see
below). `pllx_repoint_acf_refs()` reads these from the SOURCE post on every
run, resolves each reference through `pll_get_post()`, and writes the
target-language equivalent onto the target post, since nothing else ever
gives the target a value for these types to begin with.
Measured on the test site's fixture (`pll-acf-fixture.php`, extended for
Task 9): with `return_format` left at its default, `page_link` returns a
permalink **string**, never an id, and is not configurable to return one —
this is what "handle what you actually observe rather than what the
documentation implies" turned up here. `post_object` and `relationship` were
configured with `return_format => 'id'` for this pass to have a stable shape
to write; `pllx_acf_ref_id()` in `pll-import.php` also tolerates the
`return_format => 'object'` shape (`WP_Post`/array with `ID`) defensively,
though that was not the configuration measured.
**A plain `url` field stays a negative control, deliberately.** An ACF `url`
field that happens to hold an internal link is **not** re-pointed by this
pass, even though a `link` or `page_link` field holding the identical value
would be. The reasoning: `url`, `image`, `number` and `true_false` are all
generic scalar types with no reference semantics ACF itself is aware of —
treating "the string looks like this site's URL" as a signal would mean
guessing intent from content rather than from the field's declared type,
and would make a project's actual "do not touch this URL" field (exactly
what this test site's `pll_url` fixture field represents) unpredictably
mutable depending on what a translator happens to paste into it. `link`,
`page_link`, `post_object` and `relationship` are unambiguous because ACF
itself defines them as references; `url` is not, so it is left alone like
any other scalar. If a project needs a plain `url` field re-pointed, model
it as `link` or `page_link` instead.
**`clone` fields are deliberately never walked as their own type.** With the
default *seamless* display, a clone's sub-fields surface as ordinary siblings
under their own names in `get_field_objects()` and are already covered by the
branches above — walking `clone` too would re-emit the same value under a
second key. With *group* display, `get_field_objects()` returns the clone as
a **second** object (type `clone`) whose value duplicates the original
field's, backed by the same underlying meta; walking that would emit the same
text twice under two different dotted keys, and writing both back
independently risks the second write clobbering the first with a different
translation. Both shapes were probed live before reaching this conclusion —
adding a `clone` branch would open the exact "translate the same thing twice
and let the last write win" defect class this plan exists to close, not
prevent it.
**Ceiling:** one level of nesting inside `group`, `repeater` and
`flexible_content` — a group nested inside a repeater or a flexible-content
layout is not walked. Widen `pllx_acf_walk()` if a project needs more.
Verified against **Secure Custom Fields (SCF) 6.9.5** — the free,
wordpress.org fork that ships `repeater`, `group`, `flexible_content` and
`clone`, which ACF sells as PRO. ACF's free tier has none of those four types
and exposes the rest of this surface (`text`, `textarea`, `wysiwyg`, plain
`group`, plain `repeater`) through the identical API, so passing against SCF
implies passing against ACF free. ACF PRO was not available to test against
(no licence). **ACF and SCF cannot both be active** — both define
`get_field()`, `get_field_objects()` and `update_field()`, and activating the
second one over the first fatals the site. Install exactly one.
## Strings
`pll_register_string()` registers a string for translation, but only the theme or
plugin that owns a string can register it. Polylang itself registers four strings
under the `WordPress` context: `blogname`, `blogdescription`, `date_format`, and
`time_format`. Empty values are absent from the table, so a site with no tagline
shows three entries instead of four.
The `date_format` and `time_format` options contain PHP date formats, not prose.
The Spanish-locale default for `date_format` is `j \d\e F \d\e Y`; English-locale
is `F j, Y`. Translating these produces garbage. Exclude them by comparing against
`get_option('date_format')` and `get_option('time_format')` rather than by
guessing at their shape.
Theme strings written as `__()` / `_e()` are **gettext**, a different mechanism
entirely, translated through `.po`/`.mo` files and not through Polylang's string
table. Do not conflate the two.
## Activation behaviour
Activating Polylang assigns a default language only to the post types and
taxonomies that were already registered as translatable **at that moment**. A
post type or taxonomy enabled for translation later (a common retrofit step —
e.g. turning on `product`/`product_cat` after the plugin has been running for
a while) keeps every one of its existing objects with **no language assigned
at all**. `pll_get_post_language()` / `pll_get_term_language()` return `false`
for them, not the default language.
This matters for automation: a query filtered by `'lang' => $source` silently
excludes objects with no language — they never enter the result set, so
nothing downstream can see, count, or warn about them. Verified live on
Polylang 3.8.7: a WooCommerce catalogue (7 products, 9 `product_cat` terms)
enabled after initial activation had zero objects assigned any language,
while `post`/`page`/`attachment`/`category` — all present at activation —
were fully tagged. Anything that walks a site to find translatable content
must query without a `lang` filter, classify each object's language itself,
and count and report what has none — never assume "activated" means
"assigned everywhere."
On a site being retrofitted, the work is therefore not just creating
counterparts and joining them into groups — it may also require assigning a
source language to content Polylang never touched in the first place.