Changelog

Release history and version notes

1.0.2

Non-Latin slug corruption and resolution, missing-extension fatals
  • Machine Translation could not be switched on from the admin at all. Its settings tab was listed only once the feature was already enabled, and the checkbox that enables it lives inside that tab; the Addons card said "Enable here" but drew no button, because the button was reserved for registered addons and Machine Translation is built in. The tab is now always listed and the card has its button. Present since 1.0.0.
  • Three settings were silently cleared by unrelated saves. A tab only submits the controls it draws, but every key of a tab was read whether drawn or not, so a hidden control read as one the operator had unticked. Enabling Machine Translation set its auto-translate scope to "no languages"; saving URL & Routing cleared Edge Hint Redirect while the edge worker was off; saving Performance reset the background engine on sites without Action Scheduler. Each setting is now read only when its own control was drawn.
  • Fatal error on servers without the mbstring extension: the Strings admin page truncated long strings with a function WordPress does not polyfill, so the page died rather than degrading.
  • Translated content was served in the wrong language on requests that carry a non-Latin slug undecoded. WordPress stores such slugs percent-encoded and PerfLocale compared its lookups against that stored form, but two request shapes deliver the slug decoded and neither matched: a query-string request such as ?category_name=ニュース, which is what a plain-permalink site issues for every archive and page, and a URL carrying raw UTF-8 in the path. Japanese, Chinese, Korean, Arabic, Hebrew, Russian, Greek, Thai and Hindi were affected, as was any accented Latin slug such as the German "Über uns". Pretty-permalink sites were not affected on the normal path, since WordPress delivers those already encoded. Slugs are now normalised to the stored form before every comparison, using core's own routine; a pure-ASCII slug is returned unchanged.
  • Long non-Latin term names produced permanently corrupted slugs. Shortening cut by byte position and landed inside a percent-escape; the re-sanitising that followed dropped the stray percent sign but kept the hex digits, so the slug became different characters rather than fewer. Japanese, Chinese and Korean names from about 24 characters and Arabic, Hebrew and Russian from about 45 were affected every time, and the damaged slug is what hreflang, the sitemap and the switcher emitted.
  • A PO import could delete a language's translations and put nothing back. Gettext parsing drops entries it cannot decode without raising an error, so a file saved as ISO-8859-1 or Windows-1252 read as valid but empty; in Replace mode that deleted everything and imported nothing. The importer now refuses a non-UTF-8 file, and one whose entries all carry an empty translation, before anything is deleted. Exports were affected too: a carriage return anywhere made the whole file unparseable, and is now normalised on the way out.
  • Excluded paths never matched a non-Latin path, and the two places that checked them disagreed with each other. The setting stores what you type while the request arrives percent-encoded. There is now one matcher, it decodes both sides, and it requires a path boundary, so an excluded /api no longer also excludes /apifoo.
  • A refused settings save reported success. The database declines a value it cannot store rather than shortening it, so the write failed and the screen still said it saved. Re-saving a tab unchanged is still reported as success.
  • Language prefixes outside plain ASCII could not be read back. A locale such as sr_RS@latin, or one written in Cyrillic, was written into the URL correctly and then stripped by the reader, so every non-default page served the default language. Both query mode and subdirectory mode now accept the prefix the plugin itself writes.
  • Four smaller multibyte defects: machine-translation length caps counted bytes while calling them characters, job log lines were cut mid-character, adding a language with a non-Latin slug stored an unusable row and reported success, and the REST create route destroyed emoji flags.
  • A fatal error on every front-end page, on servers built without the ctype extension. Building the language tag for the Content-Language header and the hreflang tags called a function WordPress ships no replacement for, on every request, so such a server returned a fatal error instead of a page. That routine now needs no extension at all and produces identical output for every locale tested.
  • XLIFF export and import no longer die when the XML extensions are absent. Both reached for them without checking, and the export route had nothing to catch the failure. Each now stops with a message naming the package to install. The address checks guarding outbound machine translation and webhooks now fail closed rather than fatally, so those features stop working rather than the site.
  • Site Health was naming the wrong extensions. It raised a critical failure when mbstring was missing, though the only multibyte functions left are ones WordPress polyfills, and it listed json, which cannot be disabled on any supported PHP version. The check now lists only extensions that gate a real feature, names the feature beside each, and reports a recommendation rather than a critical failure.
  • WPForms confirmation messages are now translated. Field labels and the submit button were already translated at render; the confirmation is chosen at submit time from a fresh read of the form, so it needed handling there. Notifications, spam settings and the form ID are untouched, so submissions, emails and entries stay on one pipeline. A form embedded outside a single post or page still shows the source-language confirmation.
  • XLIFF imports write once per post instead of once per field. A post with a translated title, body and excerpt cost three full saves and now costs one; measured on real content, importing 40 posts went from 120 post writes to 40 with byte-for-byte identical stored output.
  • Behaviour change: an XLIFF import now creates one revision per imported post rather than one per translated field, and save_post, post_updated and the other save hooks fire once per post rather than once per field. If a plugin vetoes the write for a post, none of that post's fields are stored, where previously the fields processed before the veto had already been saved; nothing is rolled back, the write simply does not happen.
  • Behaviour change: libretranslate.com no longer skips the outbound-URL safety check, so a site configured to use that host pays one cached DNS lookup for it.
  • New setting: the Machine Translation tab gains a Custom Fields switch for translating ACF, Meta Box and Pods values, which previously had no control anywhere in the admin.
  • The Addons screen no longer labels a switched-off built-in feature "Not installed" in one place while calling it "Disabled" in another.
  • Two settings descriptions corrected: the API-key link no longer implies wp-config constants are the only way to store keys, and the edge-integration text now points at the redirect order you can actually edit rather than a list with no interface.

1.0.1

Security and reliability
  • Security: a user holding the Translator role could publish, privatise or trash any post or page they were able to edit. Translation status changes were gated only on the edit capability, bypassing the publish and delete capabilities that role deliberately withholds. Status changes now map to the target post type's own capabilities, exactly as WordPress core does. Drafting is unchanged.
  • Security: translated Contact Form 7 forms rendered the form's stored configuration into the public page — recipient addresses, mail templates and headers included — because Contact Form 7 keeps a flattened copy of every property in the post content. Translated forms now read Contact Form 7's own form property. A machine-translated form reverts to the source markup until it is re-saved in Contact Form 7's editor.
  • Security: data export filenames now carry 32 characters of entropy instead of six, exports are published atomically so a partially written file can never be served, and an export's filesystem path is hidden from REST callers without the import/export capability.
  • Security: nested credentials and URL user-info are stripped recursively from exports rather than only at the top level, and credential masking in provider errors no longer depends on the credential containing a digit.
  • Security: draft, pending, private and trashed translations no longer leak through Yoast breadcrumb structured data or the WooCommerce cart.
  • Security: custom machine-translation endpoints are now validated against IPv6 addresses as well as IPv4, and provider responses are size-capped.
  • WooCommerce: simultaneous purchases across language versions of the same product no longer lose a stock decrement. Each sale is applied to the sibling languages as a relative change the database performs atomically, so three shoppers buying three language versions of a stock-10 product at the same instant leave every version at 7 — previously two of those three sales could be lost, and the loss grew with the number of simultaneous orders. Stock status and the lookup table WooCommerce uses for shop queries are derived in the same operation that reads the quantity, so a translated product cannot display a figure a concurrent sale has already superseded.
  • WooCommerce: exchange-rate syncing merges the provider's response instead of replacing the whole stored set, so a partial response no longer wipes the currencies it omitted. A response that fails validation now counts as a failure for the circuit breaker rather than resetting it. Bulgaria's suggested currency is now EUR.
  • Content: protected nested blocks keep their inner blocks, and XLIFF export no longer strips emoji, rare CJK and other supplementary-plane characters.
  • Content: a failed table read during export now fails loudly instead of producing a valid-looking but silently incomplete backup.
  • Reliability: a failed string-translation move no longer deletes the source rows, and a transient database error while building the link map is no longer cached as an authoritative empty result.
  • Reliability: job locks can no longer be released or garbage-collected by a previous owner, and the Action Scheduler integration — including the bridge that marks a job failed when its worker is killed — now actually registers. It previously never did.
  • Performance: saving a post no longer rewrites its translations when nothing that is actually synced has changed. Every save used to issue one full update per translation regardless, bumping each translation's modified date, firing the save hooks other plugins listen to, and costing a full set of database writes per translation. Translations are now written only when a synced field really differs; what gets synced is unchanged. The perflocale/cache/flush_object purge signal still fires for each translation, so CDN integrations are unaffected.
  • Reliability: a post list containing an entry that is not a post object — WordPress can hand one to its the_posts filters when a post is permanently deleted during a page load — caused a fatal error in the front-end translated-slug preload and in the admin post-list translations column. Both now skip such entries and behave exactly as before for every real post.
  • Background jobs: deleting a job now also removes the export file it produced, instead of leaving it in the uploads folder with no record identifying it. An export whose post-write hook throws is no longer orphaned — the export succeeded, so the job keeps its result and reports the hook failure separately.
  • Background jobs: when a scheduler refuses an event — a filter veto, a duplicate, or a queue error — that refusal is now reported and logged rather than silently producing no work. On multisite, a job lookup can no longer return another site's job when a single process handles more than one site.
  • Performance: data import no longer flushes the entire object cache every 500 rows, which on sites running Redis or Memcached evicted every other plugin's cached data for the duration of the import.
  • New filter: perflocale/url/query_var renames the query variable used by query-parameter URL mode, for sites where another plugin already owns 'lang'. Register it from an mu-plugin and flush permalinks afterwards.
  • New filter: perflocale/jobs/deduplicate_admission restores the previous behaviour when a second identical background job is dispatched while the first is still in flight.
  • New filter: perflocale/breaker/probe_lease_seconds sets how long a single worker holds the half-open probe lease on a tripped circuit breaker.
  • Behaviour change: a save that changes none of the synced fields no longer writes to the translations, so save_post, post_updated and transition_post_status no longer fire for them on such a save and their modified dates stay put; exports now fail rather than silently omit a table that cannot be read; per-status translation counts may read higher on sites whose stored status had drifted; and perflocale/mt/pre_translate and perflocale/machine_translation/after now carry the source object's own language rather than the site default.
  • Security: the translation endpoint also accepted WordPress's internal post statuses. Setting a translation — or, for the site's default language, a source post — to auto-draft handed it to the WordPress routine that permanently deletes abandoned drafts, so a role deliberately denied the delete capability could still have content destroyed. Internal statuses are now refused; draft, pending and other plugins' workflow statuses are unaffected, and custom post types are judged by their own capabilities rather than the built-in post ones.
  • Security: machine translation requested through the Abilities integration bypassed the hourly per-user and site-wide translation limits, and the contention guard, that the REST endpoints enforce. All three entry points now share one admission check.
  • Background jobs: deleting a job's export file no longer follows a symbolic link, so it cannot remove a file belonging to a different job, and it verifies the file at the recorded location is still the one the job created. If the file cannot be removed the job is still deleted and the leftover is logged rather than disappearing silently.
  • Background jobs: recurring maintenance events now report whether the scheduler accepted them, and a refusal is logged with its reason instead of being indistinguishable from success. Site Health lists all four recurring events it checks, including the machine-translation usage cleanup.
  • Background jobs: a long-running command or worker no longer reuses a stale answer about whether one of the plugin's jobs is in flight. On multisite that stale answer could come from a different site and delay Action Scheduler's recovery of other plugins' stuck tasks.
  • Security: the WordPress AI Client provider called the SDK's generateText() directly. WordPress applies wp_supports_ai() and the site-wide wp_ai_client_prevent_prompt policy filter only to the snake_case form, so a site that had globally blocked AI prompts was still being prompted by this plugin. The call now goes through the policy layer.
  • Routing: query-parameter URL mode now honours the URL Prefix Format setting, so a site set to locale form serves ?lang=en-us rather than ?lang=en everywhere the plugin writes a URL, including the search form's hidden field and the WooCommerce AJAX endpoint. Requests using the other form are permanently redirected to the canonical one, so a language keeps exactly one indexable URL.
  • Routing: a language could be made unreachable by its own slug. Locales are also matched in their URL form, and a single-pass lookup let a later language's locale form overwrite an earlier language's real slug — give one language the slug de-de and another the locale de_DE, and /de-de/ served the second one. Real slugs now always win.
  • REST API: creating a language with a slug or locale that already exists returned HTTP 500 with a raw database error. It now returns 409 with slug_exists or locale_exists.
  • Background jobs: the “is this event already scheduled?” check was a no-op under Action Scheduler, because an async action has no next-run timestamp for it to find, so scheduling guards that relied on it could queue duplicate work. It now asks Action Scheduler directly.
  • Security: machine-translation rate limiting now validates the shape of its stored counter, not just its presence. A corrupted counter — from an object-cache collision or another plugin writing the same key — could previously slip past the hourly cap entirely, or turn a translation request into a fatal error.
  • Security: a new Site Health check reports whether your server actually protects the export directory. PerfLocale writes a Deny-from-all .htaccess beside every export, but nginx and Caddy ignore .htaccess and PHP cannot tell which server it is behind. The check places a temporary random file there, requests it over HTTP, and raises a critical result with the exact nginx and Caddy snippet if the file comes back.
  • Reliability: when the scheduler refuses a webhook retry, that refusal is recorded as a delivery failure instead of being discarded. Previously the retry vanished with no second attempt and no entry in the failure log. Failure-log lock contention is now reported rather than silently skipped.
  • WooCommerce: block Cart and block Checkout work again on subdirectory multisite children. A shopper browsing in a non-default language made every Store API request return a 404 there, which broke the cart. Store API messages on those sites now come back in the site's default language; single sites, subdomain and per-domain networks are unaffected.
  • Background jobs: deleting a job created before 1.0.1 no longer removes whatever file happens to sit at that job's old export path — those records predate the file-identity check, so the artifact is left to the age sweep instead. The failure log also no longer records the export path, filename or download token, only the job ID and a short hash.
  • REST API: a status WordPress refuses for a post type now returns 400 translation_status_rejected instead of 500. Nothing is written either way; the previous response told clients the server had broken when the request was at fault.
  • Caching: new perflocale/cache/purge_urls action fires when a post's public visibility changes, carrying every affected front-end URL including each translation's. A translated page is a separate URL that a full-page cache usually does not know about, so one that had been public could stay readable in the cache after being made private.
  • Backup and restore: a replace-mode import is now all-or-nothing. If the database refuses even one row the whole import rolls back and your existing data is untouched, instead of committing everything that did land and reporting success. Settings, add-on settings, the disabled-add-on list and role grants carried in the bundle roll back with it. A PO import run with replace behaves the same way.
  • Backup and restore: every write to an export file is checked. A disk that fills up, a quota that runs out, or a network share that drops a write partway through no longer produces a file that ends correctly and is corrupt in the middle — the export is abandoned and your previous backup is left in place.
  • Background jobs: a worker that cannot record itself as running no longer runs the job anyway. A failed status write used to leave the job listed as queued while its work went ahead — translations created, provider credit spent — and the queued row stayed eligible to be run a second time.
  • Background jobs: dispatching the same job twice — a double-clicked button, a retried request, the same --async command run again — now returns the job already in flight instead of queueing a second one. Different arguments, a different job type and the chunked site-translation chain all keep running in parallel.
  • Machine translation: the circuit breaker now counts a provider that answers with something unreadable. A proxy returning HTTP 200 with an error page used to clear the failure count on every call, so the breaker could never trip. When the breaker opens and its cooldown expires, exactly one request is now let through to test the provider rather than every request that arrives at once.
  • WP-CLI: import, po-import and network-import exit non-zero when the import reports errors. A malformed file previously printed a warning and then Success: with exit code 0, so a restore script could record a restore that never happened.
  • REST API: malformed XLIFF — an empty body, XML that does not parse, a document declaring entities, or an unknown target language — now returns 400 instead of 500. An XLIFF file that repeats a unit identifier is applied once instead of once per repeat.
  • Routing: translated permalinks on non-Latin sites could be generated broken. WordPress percent-encodes non-ASCII slugs, so an ordinary 26-character Japanese title becomes a 198-character slug; when two collided, the trim that made room for a -2 suffix cut through a percent-escape. Apache and nginx reject a malformed escape in a path with a 400 before WordPress runs, so that permalink could not be opened at all. The trim is now character-aware, exactly as WordPress does it for post slugs.
  • Multisite: the notice explaining that PerfLocale cannot run alongside WPML, Polylang or TranslatePress now also appears in Network Admin, where WordPress does not fire the hook it previously used — so an operator who network-activated the other plugin saw nothing at all. It now also says their translations stay in the database and can be imported after deactivating.
  • Add-ons: a failed add-on boot no longer leaves a permanent warning. WordPress replaces plugin files in place during an update, so a request landing mid-write can report a parse error for a problem that fixes itself on the next request — and that was recorded permanently, shown to every administrator, with the server's absolute file paths in the message. The record now retires as soon as the add-on boots successfully, clearing a quarantine clears its boot errors too, and the paths are stripped.

1.0.0

Initial release
  • Content translation for posts, pages, any custom post type, categories, tags, and custom taxonomies, with per-language URL slugs and configurable fallback chains when a translation is missing (show_404, show_default, redirect_default).
  • URL routing in subdirectory (/en/), subdomain (en.example.com), per-language domain, or query-parameter (?lang=de) mode, with language detection from URL, cookie, browser preference, or GeoIP. No GeoIP provider is bundled — register a source through the perflocale/geo/lookup_country filter. Rewrite rules self-heal if language patterns go missing.
  • String translation for gettext strings from any plugin or theme, with no code changes. Two storage modes: pre-generated .l10n.php files for speed, or database mode with lazy-loaded filters. Includes a built-in scanner, PO import/export, and full CLDR plural rules (up to six forms).
  • Language switcher as a Gutenberg block, shortcode, PHP template tag, widget, nav-menu item, and admin-bar item, plus native components for Blocksy, Kadence and Neve and widgets for Elementor, Beaver Builder and Bricks. Full ARIA listbox semantics with keyboard navigation and type-ahead, per-option lang/dir attributes, reduced-motion support, RTL via logical properties, and eight filters for customising the markup.
  • SEO: hreflang in both the HTML head and HTTP Link headers with x-default, sitemap alternates, and JSON-LD schema enrichment. Integrates with Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework and Slim SEO without duplicating their output. Opt-in Speculation Rules prerender and View Transitions.
  • Machine translation via DeepL, Google, Microsoft, LibreTranslate, a custom agency endpoint, or the WordPress AI Client on WP 7.0+ (which reuses your host's configured provider without a second key). Auto-translate on publish, bulk translation from the Translations page, monthly character caps, and circuit breakers around every provider.
  • Machine translation of SEO meta and custom fields: SEO titles and descriptions translate with the post (template variables protected), and ACF, Meta Box and Pods text fields can opt in. Translator edits are never overwritten.
  • Pre-flight cost estimates in bulk actions, the site-wide panel, REST and WP-CLI (--dry-run) show items, characters and budget before anything is sent, and over-budget dispatches are refused up front.
  • Whole-site translation as a resumable, chunked background job with estimate-first confirmation, startable from the Translations page, REST, or wp perflocale translate --all --async.
  • API keys resolve in env-var → wp-config.php constant → WordPress Connectors API (WP 7.0+) → database order, so containerised and version-controlled deployments can keep secrets out of the database entirely.
  • A dedicated Translator role plus seven perflocale_* capabilities gating the plugin's admin screens, REST routes and CLI commands. Translators get perflocale_translate and perflocale_use_mt; Editors also get perflocale_manage_translations and perflocale_approve_translations.
  • Block editor integration: the link picker is scoped to the language of the post being edited (so editing a /de/ post surfaces DE targets with /de/ URLs), featured images can differ per language, and a block-toolbar control translates the selected block or fills a draft from its source.
  • WooCommerce: translate products, variations, categories, attributes and attribute terms, with variation attribute names translated in cart, mini-cart, checkout and order emails. Multi-currency with scheduled rate sync — no rate provider ships with the plugin; register one through perflocale/woocommerce/exchange_rate_providers. Inventory stays single-source across languages.
  • 20+ auto-detecting integrations: WooCommerce; Elementor, Beaver Builder, Bricks, Oxygen, Oxygen 6; Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework, Slim SEO; ACF, Meta Box, Pods; Gravity Forms, Contact Form 7, WPForms; Blocksy, Kadence, Neve.
  • An addon system for third-party authors: five capability interfaces (versioned tables, declarative purge targets, custom uninstall, version gating, and card metadata), per-addon namespaced schemas, enable/disable toggles with quarantine on repeated failure, a typed settings layer with conditional fields, and WP-CLI management commands.
  • Migration importers for WPML, Polylang and TranslatePress. Translation groups come from all three; term translations from WPML and Polylang; string translations from WPML and TranslatePress; slug mappings from TranslatePress. Polylang's string catalogue is not imported — rebuild it with the scanner. Runs are batched, transaction-safe and idempotent on re-run.
  • Developer API: 200+ actions and filters, 24 REST routes under perflocale/v1, WP-CLI commands for languages, translation, strings, migration, jobs and diagnostics, XLIFF 2.0 export/import, and signed webhooks.
  • Background processing moves long-running work (imports, exports, bulk translation, migrations, site-wide scans) off the request path — Action Scheduler when available, WP-Cron otherwise — with state in a dedicated jobs table, resumable chunking, and a Jobs admin screen.
  • Multisite: per-subsite languages, translations and strings, auto-provisioned on subsite creation, with clean per-subsite uninstall and chunked network activation for large networks.
  • Performance: a three-layer cache (per-request static → object cache → transients), batch-preloaded lookups, conditional hook registration so disabled features add no listeners, JOIN-based language filtering, and an eager link map for sites under ~2,000 translation links. Larger sites should run a persistent object cache.
  • Security: prepared SQL throughout, escaped output at every render site, nonce verification on forms and AJAX, capability checks on privileged actions, and SSL verification on every outbound request. No third-party PHP libraries.
  • Privacy and accessibility: GDPR-integrated export and erasure of the data the plugin stores per user, an uninstall path that respects your delete-on-uninstall preference, and a WCAG 2.1 AA-aligned admin and frontend (labelled form fields, table captions and scopes, no inline event handlers).