Machine Translation
Machine translation (MT) lets you auto-translate new posts, existing posts in bulk, or untranslated strings with one click. It is off by default: enable it on the PerfLocale → Addons screen, and a Machine Translation subtab then appears under PerfLocale → Settings → Addons. PerfLocale supports six providers; pick the one that fits your quality, budget and compliance needs.
Providers
- DeepL - best quality for European languages + Japanese. Paid (with a free tier: 500k chars/month). EU data-residency by default. Start: deepl.com/pro. The DeepL Formality setting is sent as
prefer_more/prefer_less, so target languages that don't support formality ignore it instead of erroring. - Google Cloud Translation - broadest language coverage (130+). Paid, metered. Google Cloud account required. Higher throughput than DeepL.
- Microsoft Azure Translator - comparable quality to Google; cheaper for high volume; requires Azure account. Custom Translator models supported.
- LibreTranslate - open-source, self-hosted. No per-character cost; you run the server. Quality is decent for EU languages, variable for non-EU. Best for privacy-sensitive or offline sites.
- External Translation Agency - generic HTTP endpoint for sending content to a human-translation service or custom workflow. You configure the URL + auth; PerfLocale POSTs the payload and expects the translated text in the immediate JSON response (
{"translation": "…"}). Asynchronous callback workflows are not implemented in core; addons can hook theperflocale/mt/agency_async_responsefilter to supply a deferred translation (for example by matching therequest_idagainst a callback received out-of-band). - WordPress AI Client (WP 7.0+) - delegates translation to whatever AI provider the host site already has configured under WordPress 7.0’s core AI Client. No separate MT API key — PerfLocale reuses the OpenAI / Anthropic / Ollama / etc. provider you already pay for. Feature-detected via
function_exists('wp_ai_client_prompt')+wp_supports_ai()(so a host disabling AI per-request via theWP_AI_SUPPORTconstant or thewp_supports_aifilter is honoured). The wrapper appliesusingTemperature/usingMaxTokens/usingProvider/usingSystemInstructionvia the WP 7.0 fluent-builder pattern and finalises with->generateText(). When no underlying AI provider is configured the builder returns aWP_Error; PerfLocale converts that to aRuntimeExceptionso the circuit breaker classifies it correctly. Custom routing is available viaperflocale/mt/wp_ai_client_resolver— see the hook docs.
API keys
Every paid provider requires an API key. PerfLocale resolves it from three sources in priority order — env var > wp-config.php constant > database setting. The first non-empty source wins.
- Environment variable — e.g.
PERFLOCALE_DEEPL_API_KEY. Recommended for containers, CI, and managed hosts. Highest priority. wp-config.phpconstant — same canonical name (define( 'PERFLOCALE_DEEPL_API_KEY', '…' )). Recommended when env vars aren’t available.- Settings → Addons → Machine Translation — paste into the corresponding field. Stored in the
perflocale_settingsoption. Used when neither an env var nor a constant is set.
See API Keys: Environment Variables & Constants for the full canonical-name table and examples.
Auto-translate on publish
Toggle Auto-Translate on Publish to trigger MT when a source post is published. PerfLocale creates a translation shell for each target language and populates it via the MT provider in the background (Action Scheduler when it’s available, WP-Cron otherwise). Failures don’t block the source publish; they’re logged and retryable from the Translations page.
Auto-Translate Target Languages narrows which languages that applies to: unchecked languages still get a clean empty stub for a translator to work from. Leaving every language checked keeps newly-added languages included automatically. A companion Auto-Translate on Create toggle does the same for title, content and excerpt when a translation is created with the + Create link; see Auto-translate on Create.
Auto-translate on Create
With Auto-Translate on Create on, clicking the + Create link in a Translations box (the classic editor’s, or the one PerfLocale adds to the Contact Form 7 and WPForms form editors) creates the translation, fills its title, content and excerpt through the provider when the conditions below are met, and opens it in the editor. The link only ever makes a new translation. If that language already has one (for example, someone created it after the page was loaded), it is opened as it is: it is not created a second time and not machine-translated again, so nothing a translator has written there is overwritten.
The + Create button in the block editor’s Translations panel does not use this setting. It always creates a copy of the original and offers a link to open it. To machine-translate that copy, open it and use Translate from <language> in its Block translation panel. That button sends one request per block, and each request counts against the hourly machine-translation request limit described below, so a long post uses more of it than a single + Create does.
Machine translation on Create runs only when the person clicking has all three of these:
- the
perflocale_use_mtcapability, which grants machine translation; - WordPress’s own permission to edit the post being translated (
edit_post), because the post’s whole content is sent to the provider; - room under the hourly machine-translation request limits (500 per user and 5,000 site-wide by default, shared with the REST endpoints). A Create that uses machine translation counts as one request.
It also runs only for a language checked under Auto-Translate Target Languages (every language is checked unless you narrow the list). For an unchecked language, + Create makes a clean stub whether or not Auto-Translate on Create is on, and even when machine translation is switched off: the title, slug, post meta, featured image and terms are copied, and the content and excerpt are left empty for a translator working from scratch.
If the language is checked but any of the three is missing, + Create still creates the translation as a copy of the original for a translator to work from, and the provider is not contacted. A provider failure gives the same copy.
A new translation is copied from the post in the site’s default language when the translation group has one, which is not always the post you clicked + Create on. If your account cannot both edit and read that original, + Create is refused with “You cannot edit the original this translation is copied from.” before anything is sent to the provider. The /machine-translate REST routes apply the same check when they would create a translation, before the request counts against the rate limit. Permissions covers what else creating a translation requires.
When machine translation of a post sets out to create its translation and finds, at the moment of creating it, that the translation already exists (for example because another request created it while the provider was working), it leaves that translation as it is and does not write its output there. Bulk translate, WP-CLI and the /machine-translate REST routes report this as “This translation already exists, so the machine translation was not written to it.” + Create opens the existing translation instead. (For a term, the /machine-translate/object route answers with the status exists instead, or writes its output over the term when the request asks to overwrite and you may edit that term.)
Bulk translate
From the Translations page, multi-select rows and pick Translate via <provider> from the Bulk actions dropdown (the label carries the active provider’s name, e.g. “Translate via DeepL”). Small batches run inline; larger ones queue as a bulk_translate background job visible under PerfLocale → Jobs. The sync/async split is governed by the bulk_translate threshold under Settings → Performance → Background Thresholds (default 25, counted as source posts × target languages). The monthly character limit applies either way, as do the hourly request ceilings on the REST endpoints (500 per user and 5,000 site-wide by default, both filterable). For very large batches you can also use WP-CLI: wp perflocale translate --all --to=de --post-type=post --skip-existing.
Before anything is sent to the provider, the bulk action shows a character estimate for the selection (items, characters, and how many already-translated pairs will be skipped) and asks for confirmation. A "Translate the entire site" panel on the same page starts a resumable background chain over every published post of the selected post types — it requires an estimate first, runs in bounded chunks, never overwrites existing translations, and can be cancelled from the Jobs page at any time. Dispatches that would exceed the monthly character limit are refused up front (see below).
Monthly character limit
Optional cap on MT usage per calendar month. Hit the cap → auto-translate + bulk translate pause until next month (manual translate via the editor button still works, since you’re explicitly opting in). Tools → Site Health flags the usage as a recommendation at 80% of the limit and as critical at 95%.
Bulk operations are checked against the limit before they start: a bulk or site-wide dispatch whose estimate would exceed the remaining monthly budget is refused with the numbers in the message, instead of burning quota until the cap trips mid-run. The check is governed by the mt_enforce_cap_on_bulk setting (on by default). It has no admin field — set it to false in the perflocale_settings option if you prefer the run-until-cap behaviour.
SEO meta and custom fields
Machine translation can also cover meta fields on the translated post. SEO titles, descriptions, and social text from the supported SEO plugins are translated by default (template variables like %title% or %%sitename%% are protected and pass through unchanged); focus keywords are deliberately left untouched. That default lives in the mt_meta_seo setting.
Text fields from ACF, Meta Box, and Pods are not translated unless you opt in: tick Custom Fields in the Machine Translation settings (the mt_meta_custom_fields setting, off by default). Field values are often names, SKUs, URLs or numbers that must not be translated, so turn it on only when your custom fields hold prose.
Two safety rules apply everywhere: a value a translator has already edited is never overwritten, and a translation that loses a placeholder is rejected (the field keeps its source value and the reason is recorded on the translation post). Developers can add their own keys with the perflocale/mt/translatable_meta_keys filter.
Blocks marked “Do not translate”
In the block editor, the PerfLocale menu in a block’s toolbar has Mark “Do not translate”. The menu is offered on blocks that hold text, such as paragraphs, headings, lists and quotes, and not on layout blocks such as Group or Columns. When a whole post is machine-translated (on publish, on Create, in bulk, with wp perflocale translate, or through the /machine-translate REST routes), each marked block that has content is swapped for a placeholder before the request goes out: its markup, its attributes and every block inside it stay out of the request. The block is then put back unchanged in the translation. If the provider’s response leaves out one of the placeholders, the translation’s content keeps the original text instead, so nothing marked is lost (the title and excerpt are still translated), and a line goes to the PHP error log.
This holds at any nesting depth. PerfLocale reads the marker on blocks nested up to 50 levels deep. Deeper than that it does not look at blocks one by one: a block there that is marked, or that contains a marked block, is kept out of the request whole. Unmarked blocks inside it therefore stay untranslated too, and a line in the PHP error log says so.
The editor’s own block-level actions treat the marker differently. On an original, Translate to <language> in the block toolbar and in the Block translation sidebar panel leaves out a marked block that holds text itself, but still translates the blocks inside a marked container such as a Quote or a List. On a translation, Fill in from <language> source in the block toolbar and Translate from <language> in the Block translation panel read the matching block in the original and send its text to the provider without checking whether that block is marked there. Translate from leaves out only blocks that hold text and are marked in the translation itself.
What comes back is sanitised
HTML returned by a provider is run through wp_kses() before it is stored. The allowlist is derived from WordPress’ own wp_kses_allowed_html( 'post' ) rather than hand-maintained, so it tracks core: whatever an editor is allowed to save, a translation is allowed to contain. Three tags core permits are dropped (object, textarea, button — no legitimate reason for a translation to introduce them) and source is added so <video> / <audio> markup survives the round trip. wp_kses() also neutralises javascript: URLs and event-handler attributes. Adjust the list with perflocale/mt/allowed_html.
Privacy
When you trigger MT, the plugin sends the post’s title + content + excerpt to the provider you chose, minus any blocks marked “Do not translate” when the whole post is translated. See the External Services section of the plugin’s readme for each provider’s ToS / Privacy links + what data is sent. For fully-local translation, use self-hosted LibreTranslate.
Reliability under provider failure
Every MT call goes through a per-provider circuit breaker. When the active provider starts returning 401 / 403 (bad key), 429 (rate limit), or 5xx (transient), the breaker trips after N failures in a sliding window and subsequent calls short-circuit instantly — no more burning 3 retries × up to 30s timeout per row when DeepL or Google is having a bad day.
The default thresholds:
- Auth errors (rotated / revoked API key) — breaker opens on the FIRST hit. No point retrying.
- Rate-limit / transient errors — breaker opens after 5 failures in 5 minutes, stays open for 5 minutes, then probes once. Once is literal: the probe holds a short lease, so when the cooldown expires under load exactly one request goes to the provider and the rest keep getting the refusal until that probe reports back.
- Unusable success responses — a provider or proxy that answers HTTP 200 with something the plugin cannot read (an HTML error page, a captive-portal interstitial, truncated JSON) counts as a failure and trips the breaker like any other. The call was paid for and produced nothing, so treating it as a success would leave a broken endpoint being called forever.
Operator visibility: open breakers appear in Tools → Site Health under "PerfLocale circuit breakers" with a one-click reset link per breaker. The PHP error log also gets a single line on each transition (no spam).
Custom fallback: catch \PerfLocale\Concurrency\BreakerOpenException in your own MT call sites to route to a fallback (a cached string, the source text, a "service unavailable" notice) instead of conflating with genuine downstream errors.
try {
$translated = $mt_service->translate_text( $text, 'en', 'de' );
} catch ( \PerfLocale\Concurrency\BreakerOpenException $e ) {
// MT provider currently in cooldown — degrade gracefully.
$translated = my_cached_lookup( $text ) ?? $text;
}Tune trip thresholds + cooldown via the breaker hooks. Disable the whole subsystem (for debugging) via add_filter( 'perflocale/breaker/disabled', '__return_true' ).