Content Translation

Translate your site’s content - posts, pages, custom post types, taxonomies, slugs, synced patterns and block-theme menus - with precision and control.

Translate Every Content Type

Posts, pages, custom post types, categories, tags, and custom taxonomies. Each translation is a full WordPress post with its own URL, SEO metadata, and revisions.

PerfLocale dashboard with per-language progress bars showing percentage translated for posts, pages and products
Per-language progress, per post type - 100 % when every bar is green

Translation Status Tracking

Follow each translation through a clear pipeline from empty to published. Filter by status to see what still needs work at a glance.

Content Sync

Keep featured images, menu order, dates, and selected custom fields in sync across all language versions automatically. Edit once on the source post; every translation picks up the change.

What can be translated

PerfLocale ships with post and page as translatable post types, and category and post_tag as translatable taxonomies. PerfLocale → Settings → Translation lists every public post type and every public taxonomy registered on your site as a checkbox, so a custom type becomes translatable the moment you tick it. Developers can shape both lists from code instead, with the perflocale/translatable_post_types and perflocale/translatable_taxonomies filters.

Media is handled differently, and deliberately so: PerfLocale does not clone attachments. The attachment edit screen instead grows an Alt Text, Caption and Description field for every active non-default language. Posts that do not yet have a separate translation in every language also get a Featured Image per Language panel, so one post can show a different image per language without being duplicated at all.

How translations are linked

Every translatable object belongs to a translation group, and a link row ties one object to one language inside that group, carrying that pairing’s status and source. Two database-level unique keys keep the model honest: a language may appear only once inside a group, and an object may be linked to a given language only once for its type - so a page cannot quietly acquire a second German version. Because the linking lives outside wp_posts, each translation stays an ordinary WordPress post, with its own ID, editor, revisions and permalink. A post of a translatable type that still has no language is assigned the site default language automatically on save.

Creating a translation

Clicking + Create beside a language checks that you may create the translation and then creates it - the classic-editor metabox redirects you straight into its editor, and the block editor offers an Open translation link the moment it lands. The content source is the group’s default-language post whenever it has one, even when you press Create from a French translation while adding German - while that original exists, a translation is never seeded from another translation. That is why the check covers the original too: you need the perflocale_translate capability, edit rights on the post you started from, edit and read rights on that original, and WordPress’s own permission to create that kind of content (a synced pattern needs publish_posts). The new post’s status comes from the Default Translation Status setting: Empty, Draft or Pending Review. Empty and Draft both create a WordPress draft, which the Translations screen shows as Draft; Pending Review creates a post pending review. For hierarchical types the translation is attached to its parent’s translation in the same language, but only when that parent really exists, is not in the trash, and is of the same post type - failing that, the child stays top level rather than inheriting a broken URL.

The create step then copies post meta, the featured image and taxonomy terms. Meta copying skips WordPress’s own bookkeeping keys and refuses outright to copy anything whose key looks like a credential - _password, _secret, _token, _api_key, stripe, paypal and friends, matched as whole parts of the key, camelCase parts included, so book_author is copied and api_auth and stripeSecretKey are not. Both lists are filterable, through perflocale/translation/excluded_meta_keys and perflocale/translation/dangerous_meta_patterns. Each copy step is isolated, so one failure does not skip the others: whatever failed is recorded on the new post and surfaced in the editor as a one-shot notice naming the missing pieces.

Creates are serialised per source-and-target pair, so a double-click, a cron run and an editor save cannot between them produce two German copies of the same page. A second request for the same pair waits up to a few seconds for the first to finish and, when it does, gets that translation back rather than an error. Auto-Create Stubs (off by default) does the same job on publish: the first time a default-language post goes live, an empty stub appears for every other active language.

Per-language status

Each translation shows one of five statuses, each with its own badge colour in the admin. Four of them come from the translation’s own post; needs_update is a flag PerfLocale keeps on the link row (how a status is decided):

StatusBadgeMeaning
emptyGreyNo translation in this language yet, or the translation is in the Trash.
draftBlueThe translation is a draft: a translator is working on it.
pendingAmberThe translation is pending review - shown as Pending Review - awaiting approval.
publishedGreenThe translation is published. Visitors in that language see it.
needs_updateRedThe source changed after this translation was last written.

PerfLocale → Translations renders your translatable content as that matrix: one row per source post, one column per language, one badge per cell linking through to the sibling’s edit screen. Filter by post type, language, status or source, search by title, then mark a selection Needs Update by hand - or, with a machine-translation provider configured, translate the selection in bulk. Every link row also records where its translation came from - manual, machine translation, WP-CLI, the REST API, or a migration import from WPML, Polylang or TranslatePress - so you can pull up everything a machine wrote and review only those. More on the Translations matrix →

When the source changes

PerfLocale stores a SHA-256 hash of each source post’s title, content and excerpt, and recomputes it on every publish-status save. If the hash differs and the post that changed is the default-language member of its group, every other link in the group flips to needs_update in a single statement and the perflocale/content/changed action fires. Editing a translation never flags its siblings or its source, so a translator polishing the German copy does not tell the French translator to start over. Terms behave the same way, hashing name and description.

Fields that follow the source

Beyond featured image and menu order, the Sync Fields setting can also keep page parent, publish date, author, comment status, ping status and arbitrary meta keys aligned across a group. Keys you configure keep full mirror semantics - a save overwrites the siblings, a delete clears them - which is right for a value that is genuinely owned by the original and contains no words, such as a flag or a numeric setting. Do not mirror anything holding translatable text: the sync runs in both directions, so it would overwrite a translator’s work. Page-builder layout documents are seeded once, then owned by each language. Keys contributed by integrations, such as SEO titles or custom-field values, are seed-only: copied from the default-language post into a sibling that has none yet, then owned by that sibling’s translator and never overwritten again. A field the translator deliberately empties stays empty, and machine translation and seeding do not fill it again (ACF, Meta Box and the supported SEO plugins), and keys named like credentials are never copied. The split is itself filterable, via perflocale/sync/mirror_meta_keys. Page parent, publish date and seed-only keys travel in a single direction, outward from the default language, and a post can be lifted out of the graph entirely with the Independent across languages checkbox on its editor screen. A save copies Sync Fields only to the translations the person saving may edit; the others keep their values and can be marked Needs Update until someone who may edit the original saves them. How Sync Fields decide what reaches each translation → When a Sync Field reaches a sibling, only that field and the sibling’s modified date change: its content, title, excerpt, status and publish date (unless the publish date is the synced field) stay exactly as they are, whoever saves. A sibling that a synced page parent moves under another parent gets a slug that is unique among that parent’s children.

The featured image and your Sync Fields meta keys follow a change however it is saved, not only from the editor: set_post_thumbnail(), update_post_meta(), a WooCommerce save from code, or the REST API’s featured_media and meta fields. Permanently deleting one post of a group does not clear those fields on the others. Meta written while a visitor views a page on the front end is not copied to the other languages; the next save of that post copies it, so page views never write to translations. On multisite, each site follows its own Sync Fields setting, also when another site writes to it.

Taxonomies and slugs

When a post translation is created, its terms come with it: for every translatable taxonomy the terms are sorted parents-first, each one’s translation is found or created, and the results are assigned to the new post - so the German article lands in the German category, not the English one. Keeping term parent structure identical across languages is on by default and maps each parent to that language’s own translated parent; turn it off when a market deliberately needs a flatter tree. Term names, slugs and descriptions always stay translator-owned.

Slugs need two mechanisms, because WordPress treats posts and terms differently. Posts may share a slug across languages: PerfLocale steps into WordPress’s uniqueness check and collapses the usual -2 suffix back to the intended slug when every conflicting post of that type is in another language - a genuine same-language collision keeps its suffix, because those two URLs really would be identical. Terms cannot share a slug at all, so a translated term is stored as base-de in wp_terms while its public display slug is recorded separately, and the URL uses the clean base. A trailing suffix is only ever stripped when it matches a real active language slug, so green-tea and sci-fi are left alone.

In the editor

In the block editor a Translations panel sits in the document sidebar: one row per active language with Create or Edit, the current language marked Current, and - on a post that has no language yet - a set of Set as buttons to declare which language it was written in. The classic editor gets the equivalent as a side metabox, with a language dropdown in place of those buttons. Both carry the same two per-post flags: Independent across languages, and Hide from hreflang and sitemap alternates for a translation you would rather search engines did not treat as an alternate.

Outside the editor, every translatable post type’s list table gains Language and Translations columns, an All Languages filter and a language field in Quick Edit; taxonomy screens get the same treatment. All of it is scriptable too, through the perflocale/v1 REST namespace, wp perflocale translations on the command line, and XLIFF export and import for agency workflows. Read the content-translation docs →

← Back to Features