{"id":297618,"date":"2026-08-31T13:13:18","date_gmt":"2026-08-31T13:13:18","guid":{"rendered":"https:\/\/wordpress.org\/plugins\/perflocale\/"},"modified":"2026-09-09T16:54:20","modified_gmt":"2026-09-09T16:54:20","slug":"perflocale","status":"publish","type":"plugin","link":"https:\/\/lmo.wordpress.org\/plugins\/perflocale\/","author":23074463,"comment_status":"closed","ping_status":"closed","template":"","meta":{"version":"1.0.4","stable_tag":"1.0.4","tested":"7.1","requires":"6.4","requires_php":"8.1","requires_plugins":null,"header_name":"PerfLocale","header_author":"Alex Georgiev","header_description":"Performance-first multilingual plugin for WordPress. Translate posts, pages, products, taxonomies, strings, and slugs without slowing your site down.","assets_banners_color":"575761","last_updated":"2026-09-09 16:54:20","external_support_url":"","external_repository_url":"","donate_link":"","header_plugin_uri":"https:\/\/perflocale.com","header_author_uri":"https:\/\/alexgv.com","rating":5,"author_block_rating":0,"active_installs":0,"downloads":234,"num_ratings":1,"support_threads":2,"support_threads_resolved":0,"author_block_count":0,"sections":["description","installation","faq","changelog"],"tags":{"1.0.0":{"tag":"1.0.0","author":"alexgeorgiev","date":"2026-08-31 13:13:04","revision":3674300},"1.0.1":{"tag":"1.0.1","author":"alexgeorgiev","date":"2026-09-05 15:28:51","revision":3682581},"1.0.2":{"tag":"1.0.2","author":"alexgeorgiev","date":"2026-09-06 06:20:42","revision":3683043},"1.0.3":{"tag":"1.0.3","author":"alexgeorgiev","date":"2026-09-08 05:28:58","revision":3685939},"1.0.4":{"tag":"1.0.4","author":"alexgeorgiev","date":"2026-09-09 16:54:20","revision":3688705}},"upgrade_notice":{"1.0.4":"<p>Translates the site title, tagline, synced patterns and block-theme menus. Fixes a translated site title overwriting the original, a multisite call returning the wrong blog&#039;s name, and order emails resolving the wrong language.<\/p>","1.0.3":"<p>Multisite: registering a webhook now needs network-administrator permissions - a breaking change, with a filter to restore the old rule. Also fixes WooCommerce order emails in the wrong language, percent signs deleted from titles, and the admin on phones and tablets.<\/p>","1.0.2":"<p>Machine Translation could not be switched on from the admin at all, and three settings were silently cleared by unrelated saves. Also fixes a fatal on servers without mbstring, translates WPForms confirmations, and makes XLIFF imports about three times cheaper.<\/p>","1.0.1":"<p>Security release. Fixes capability bypasses that let the Translator role publish, trash or destroy content, closes a machine-translation quota bypass, stops translated Contact Form 7 forms exposing mail settings, and fixes a WooCommerce race that lost stock when several languages sold at once.<\/p>","1.0.0":"<p>Initial release of PerfLocale.<\/p>"},"ratings":{"1":0,"2":0,"3":0,"4":0,"5":1},"assets_icons":{"icon-128x128.png":{"filename":"icon-128x128.png","revision":3674295,"resolution":"128x128","location":"assets","locale":"","width":128,"height":128},"icon-256x256.png":{"filename":"icon-256x256.png","revision":3674295,"resolution":"256x256","location":"assets","locale":"","width":256,"height":256},"icon.svg":{"filename":"icon.svg","revision":3674295,"resolution":false,"location":"assets","locale":false}},"assets_banners":{"banner-1544x500.png":{"filename":"banner-1544x500.png","revision":3674295,"resolution":"1544x500","location":"assets","locale":"","width":1544,"height":500},"banner-772x250.png":{"filename":"banner-772x250.png","revision":3674295,"resolution":"772x250","location":"assets","locale":"","width":772,"height":250}},"assets_blueprints":{"blueprint.json":{"filename":"blueprint.json","revision":3688708,"resolution":false,"location":"assets","locale":"","contents":"{\"$schema\":\"https:\\\/\\\/playground.wordpress.net\\\/blueprint-schema.json\",\"meta\":{\"title\":\"PerfLocale \\u2014 live demo\",\"description\":\"A four-language WordPress site with PerfLocale already configured: English, German, French and Italian, with translated content and one deliberate gap so you can see the fallback behaviour.\",\"author\":\"perflocale\"},\"landingPage\":\"\\\/wp-admin\\\/admin.php?page=perflocale\",\"preferredVersions\":{\"php\":\"8.2\",\"wp\":\"latest\"},\"steps\":[{\"step\":\"login\",\"username\":\"admin\",\"password\":\"password\"},{\"step\":\"installPlugin\",\"pluginData\":{\"resource\":\"wordpress.org\\\/plugins\",\"slug\":\"perflocale\"},\"options\":{\"activate\":true}},{\"step\":\"setSiteOptions\",\"options\":{\"blogname\":\"Acme Bakery\",\"blogdescription\":\"Bread that takes its time\",\"permalink_structure\":\"\\\/%postname%\\\/\"}},{\"step\":\"runPHP\",\"code\":\"<?php\\nrequire_once '\\\/wordpress\\\/wp-load.php';\\n\\n\\\/*\\n * Bail cleanly rather than fatally if the plugin is not active. On the\\n * wordpress.org \\\"Live Preview\\\" the directory supplies the plugin itself, so\\n * the install step above may be redundant there; keeping it means this same\\n * file also works when opened directly from a blueprint-url, and this guard\\n * means neither path can white-screen a visitor if activation is skipped.\\n *\\\/\\nif ( ! class_exists( '\\\\\\\\PerfLocale\\\\\\\\Plugin' ) ) {\\n\\treturn;\\n}\\n\\n\\\/*\\n * Provision a four-language demo.\\n *\\n * Mirrors what `wp perflocale languages add <locale>` does, so the rows are\\n * identical to a real install: the display name, native name, flag and text\\n * direction all come from the plugin's own bundled table rather than being\\n * derived from the locale string.\\n *\\\/\\n$plugin  = \\\\PerfLocale\\\\Plugin::get_instance();\\n$repo    = $plugin->get( 'lang_repo' );\\n$presets = (array) require WP_PLUGIN_DIR . '\\\/perflocale\\\/data\\\/languages.php';\\n\\nforeach ( array( 'en_US', 'de_DE', 'fr_FR', 'it_IT' ) as $locale ) {\\n\\tif ( $repo->find_by_locale( $locale ) ) {\\n\\t\\tcontinue;\\n\\t}\\n\\n\\t$preset = null;\\n\\tforeach ( $presets as $candidate ) {\\n\\t\\tif ( is_array( $candidate ) && strcasecmp( (string) ( $candidate['locale'] ?? '' ), $locale ) === 0 ) {\\n\\t\\t\\t$preset = $candidate;\\n\\t\\t\\tbreak;\\n\\t\\t}\\n\\t}\\n\\n\\t$slug = (string) ( $preset['slug'] ?? strtolower( substr( $locale, 0, 2 ) ) );\\n\\n\\t$repo->insert(\\n\\t\\tarray(\\n\\t\\t\\t'slug'           => $slug,\\n\\t\\t\\t'locale'         => $locale,\\n\\t\\t\\t'name'           => (string) ( $preset['name'] ?? $locale ),\\n\\t\\t\\t'native_name'    => (string) ( $preset['native_name'] ?? $locale ),\\n\\t\\t\\t'flag'           => (string) ( $preset['flag'] ?? $slug ),\\n\\t\\t\\t'is_active'      => 1,\\n\\t\\t\\t\\\/*\\n\\t\\t\\t * Always insert as non-default and promote below. Writing\\n\\t\\t\\t * is_default=1 here would leave the table with TWO defaults,\\n\\t\\t\\t * because set_default() is what demotes the previous one.\\n\\t\\t\\t *\\\/\\n\\t\\t\\t'is_default'     => 0,\\n\\t\\t\\t'text_direction' => (string) ( $preset['text_direction'] ?? 'ltr' ),\\n\\t\\t)\\n\\t);\\n}\\n\\n$english = $repo->find_by_locale( 'en_US' );\\nif ( $english ) {\\n\\t$repo->set_default( (int) $english->id );\\n}\\n\\n\\\/*\\n * Translate the two posts WordPress ships with, so the dashboard has real\\n * progress to show and the front-end switcher has somewhere to switch to.\\n *\\n * Italian is deliberately left off the page: that is what makes the dashboard\\n * worth looking at, and visiting \\\/it\\\/sample-page\\\/ then demonstrates the\\n * missing-translation fallback rather than a 404.\\n *\\\/\\n$manager = new \\\\PerfLocale\\\\Translation\\\\PostTranslationManager(\\n\\t$plugin->get( 'cache' ),\\n\\t$plugin->get( 'settings' )\\n);\\n\\n$content = array(\\n\\t'hello-world' => array(\\n\\t\\t'de' => array( 'Hallo Welt!', 'Willkommen bei WordPress. Das ist dein erster Beitrag. Bearbeite oder l\\u00f6sche ihn und beginne mit dem Schreiben!' ),\\n\\t\\t'fr' => array( 'Bonjour tout le monde\\u00a0!', 'Bienvenue sur WordPress. Ceci est votre premier article. Modifiez-le ou supprimez-le, puis lancez-vous\\u00a0!' ),\\n\\t\\t'it' => array( 'Ciao mondo!', 'Benvenuto su WordPress. Questo \\u00e8 il tuo primo articolo. Modificalo o eliminalo, e inizia a scrivere!' ),\\n\\t),\\n\\t'sample-page' => array(\\n\\t\\t'de' => array( 'Beispielseite', 'Dies ist eine Beispielseite. Sie unterscheidet sich von einem Beitrag, weil sie an einer festen Stelle bleibt.' ),\\n\\t\\t'fr' => array( \\\"Page d'exemple\\\", \\\"Ceci est une page d'exemple. Elle diff\\u00e8re d'un article car elle reste au m\\u00eame endroit.\\\" ),\\n\\t),\\n);\\n\\nforeach ( $content as $source_slug => $targets ) {\\n\\t$type   = 'sample-page' === $source_slug ? 'page' : 'post';\\n\\t$source = get_page_by_path( $source_slug, OBJECT, $type );\\n\\n\\tif ( ! $source instanceof WP_Post ) {\\n\\t\\tcontinue;\\n\\t}\\n\\n\\tforeach ( $targets as $lang_slug => $strings ) {\\n\\t\\t$translation_id = $manager->create_translation( (int) $source->ID, $lang_slug, true );\\n\\n\\t\\tif ( ! $translation_id ) {\\n\\t\\t\\tcontinue;\\n\\t\\t}\\n\\n\\t\\t\\\/*\\n\\t\\t * create_translation() deliberately produces a draft, so the front end\\n\\t\\t * keeps serving the source language until a human approves the copy.\\n\\t\\t * A demo wants them live, so publish explicitly.\\n\\t\\t *\\\/\\n\\t\\twp_update_post(\\n\\t\\t\\tarray(\\n\\t\\t\\t\\t'ID'           => (int) $translation_id,\\n\\t\\t\\t\\t'post_title'   => $strings[0],\\n\\t\\t\\t\\t'post_content' => $strings[1],\\n\\t\\t\\t\\t'post_status'  => 'publish',\\n\\t\\t\\t)\\n\\t\\t);\\n\\t}\\n}\\n\\n\\\/*\\n * The language-prefixed rules (\\\/de\\\/, \\\/fr\\\/, \\\/it\\\/) are generated from the\\n * language table, which did not exist when WordPress last built its rewrite\\n * rules. Rebuild once so the front end resolves immediately.\\n *\\\/\\nflush_rewrite_rules( false );\\n\"}]}"}},"all_blocks":{"perflocale\/language-switcher":{"$schema":"https:\/\/schemas.wp.org\/trunk\/block.json","apiVersion":3,"name":"perflocale\/language-switcher","version":"1.0.1","title":"Language Switcher","category":"widgets","icon":"translation","description":"Display a language switcher for visitors to change languages.","keywords":["language","translation","multilingual","perflocale"],"textdomain":"perflocale","supports":{"html":false,"align":["left","center","right","wide","full"],"className":true,"spacing":{"padding":true,"margin":true},"color":{"background":true,"text":true},"typography":{"fontSize":true}},"attributes":{"display":{"type":"string","default":"dropdown"},"layout":{"type":"string","default":"horizontal"},"style":{"type":"string","default":"flags_names"},"nameFormat":{"type":"string","default":"native"},"triggerFormat":{"type":"string","default":"inherit"},"hideCurrent":{"type":"boolean","default":true},"showUntranslated":{"type":"boolean","default":false},"untranslatedLink":{"type":"string","default":"homepage"},"arrowStyle":{"type":"string","default":"single"},"showFlags":{"type":"boolean","default":true},"showNames":{"type":"boolean","default":true},"fontSize":{"type":"number","default":14},"flagSize":{"type":"number","default":20},"gap":{"type":"number","default":8}},"editorScript":"file:.\/index.js","style":"perflocale-frontend","blockHooks":{"core\/site-title":"after"}},"perflocale\/if-language":{"$schema":"https:\/\/schemas.wp.org\/trunk\/block.json","apiVersion":3,"name":"perflocale\/if-language","version":"1.0.1","title":"Show If Language","category":"theme","icon":"filter","description":"Wrap inner content so it only renders when the visitor's language matches the selected set.","keywords":["language","conditional","multilingual","perflocale"],"textdomain":"perflocale","supports":{"html":false,"align":false,"color":{"background":false,"text":false},"spacing":{"margin":false,"padding":false}},"attributes":{"languages":{"type":"array","default":[],"items":{"type":"string"}},"invert":{"type":"boolean","default":false}},"editorScript":"file:.\/index.js","editorStyle":"perflocale-block-editor"}},"tagged_versions":["1.0.0","1.0.1","1.0.2","1.0.3","1.0.4"],"block_files":[],"assets_screenshots":{"screenshot-1.png":{"filename":"screenshot-1.png","revision":3674295,"resolution":"1","location":"assets","locale":"","width":1600,"height":1000},"screenshot-2.png":{"filename":"screenshot-2.png","revision":3674295,"resolution":"2","location":"assets","locale":"","width":1600,"height":1000},"screenshot-3.png":{"filename":"screenshot-3.png","revision":3674295,"resolution":"3","location":"assets","locale":"","width":1600,"height":1000},"screenshot-4.png":{"filename":"screenshot-4.png","revision":3674295,"resolution":"4","location":"assets","locale":"","width":1600,"height":1000},"screenshot-5.png":{"filename":"screenshot-5.png","revision":3674295,"resolution":"5","location":"assets","locale":"","width":1600,"height":1000},"screenshot-6.png":{"filename":"screenshot-6.png","revision":3674295,"resolution":"6","location":"assets","locale":"","width":1600,"height":1000},"screenshot-7.png":{"filename":"screenshot-7.png","revision":3674295,"resolution":"7","location":"assets","locale":"","width":1600,"height":1000},"screenshot-8.png":{"filename":"screenshot-8.png","revision":3674295,"resolution":"8","location":"assets","locale":"","width":1600,"height":1000},"screenshot-9.png":{"filename":"screenshot-9.png","revision":3674295,"resolution":"9","location":"assets","locale":"","width":1600,"height":1000}},"screenshots":{"1":"Front-end language switcher - block, shortcode, or template tag rendered as an accessible ARIA listbox, plus an optional append to classic theme menus","2":"WooCommerce cart in German - translated product titles, attributes, and currency","3":"PerfLocale dashboard - per-language translation progress for every post type, with draft and outdated counts","4":"Settings - Export &amp; Import: bring existing translations across from WPML, Polylang, or TranslatePress as a background job that is safe to re-run","5":"Languages screen - add languages, set the default, and mark text direction","6":"Settings - Performance: string-translation mode (files or database), object cache, and slug preloading","7":"Strings screen - translate gettext strings from any plugin or theme, with PO export and import","8":"Settings - URL &amp; Routing: missing-translation action and per-language fallback chains","9":"Jobs - bulk and whole-site translation runs in the background: chunked, resumable, with automatic retries and per-blog status"}},"plugin_section":[262246],"plugin_tags":[104,1385,1734,99,2333],"plugin_category":[48],"plugin_contributors":[232498],"plugin_business_model":[],"class_list":["post-297618","plugin","type-plugin","status-publish","hentry","plugin_section-dashboard-widgets","plugin_tags-i18n","plugin_tags-language","plugin_tags-localization","plugin_tags-multilingual","plugin_tags-translation","plugin_category-language-tools","plugin_contributors-alexgeorgiev","plugin_committers-alexgeorgiev"],"banners":{"banner":"https:\/\/ps.w.org\/perflocale\/assets\/banner-772x250.png?rev=3674295","banner_2x":"https:\/\/ps.w.org\/perflocale\/assets\/banner-1544x500.png?rev=3674295","banner_rtl":false,"banner_2x_rtl":false},"icons":{"svg":"https:\/\/ps.w.org\/perflocale\/assets\/icon.svg?rev=3674295","icon":"https:\/\/ps.w.org\/perflocale\/assets\/icon.svg?rev=3674295","icon_2x":false,"generated":false},"screenshots":[{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-1.png?rev=3674295","caption":"Front-end language switcher - block, shortcode, or template tag rendered as an accessible ARIA listbox, plus an optional append to classic theme menus"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-2.png?rev=3674295","caption":"WooCommerce cart in German - translated product titles, attributes, and currency"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-3.png?rev=3674295","caption":"PerfLocale dashboard - per-language translation progress for every post type, with draft and outdated counts"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-4.png?rev=3674295","caption":"Settings - Export &amp; Import: bring existing translations across from WPML, Polylang, or TranslatePress as a background job that is safe to re-run"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-5.png?rev=3674295","caption":"Languages screen - add languages, set the default, and mark text direction"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-6.png?rev=3674295","caption":"Settings - Performance: string-translation mode (files or database), object cache, and slug preloading"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-7.png?rev=3674295","caption":"Strings screen - translate gettext strings from any plugin or theme, with PO export and import"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-8.png?rev=3674295","caption":"Settings - URL &amp; Routing: missing-translation action and per-language fallback chains"},{"src":"https:\/\/ps.w.org\/perflocale\/assets\/screenshot-9.png?rev=3674295","caption":"Jobs - bulk and whole-site translation runs in the background: chunked, resumable, with automatic retries and per-blog status"}],"raw_content":"<!--section=description-->\n<p>PerfLocale is a <strong>performance-first multilingual plugin<\/strong> for WordPress. A 3-layer cache, batch-preloaded queries, and conditional hook registration keep its own code to a small fraction of total page time.<\/p>\n\n<h4>What you get<\/h4>\n\n<ul>\n<li><strong>Content translation<\/strong> - posts, pages, any custom post type, taxonomies, and URL slugs, with translation-status tracking. Synced patterns and block-theme navigation menus are translatable too (opt-in)<\/li>\n<li><strong>URL routing<\/strong> - subdirectory (<code>\/en\/<\/code>), subdomain (<code>en.example.com<\/code>), per-domain, or query-parameter (<code>?lang=en<\/code>) modes; auto-detect from URL, cookie, browser, or an edge\/CDN hint<\/li>\n<li><strong>String translation<\/strong> - gettext strings from any plugin or theme, file-based (<code>.l10n.php<\/code>) or database-mode, with full CLDR plural rules (Arabic 6 forms, Russian 3) and context support. The site title and tagline appear there too<\/li>\n<li><strong>Language switcher<\/strong> - block, shortcode, widget, menu, admin-bar, and template tags with full ARIA listbox accessibility<\/li>\n<li><strong>SEO<\/strong> - hreflang (HTML + HTTP) and sitemap alternates; integrates with Yoast, Rank Math, AIOSEO, SEOPress, The SEO Framework, and Slim SEO<\/li>\n<li><strong>Machine translation<\/strong> - DeepL, Google, Microsoft, LibreTranslate, a custom agency endpoint, and the WordPress 7.0 AI Client, with monthly usage caps<\/li>\n<li><strong>Translator role<\/strong> - a dedicated role with translation-only capabilities for your translation staff<\/li>\n<li><strong>E-commerce<\/strong> - WooCommerce product\/variation\/attribute translation, multi-currency (rates supplied by your own provider hook), inventory sync, localized order emails<\/li>\n<li><strong>Reliability<\/strong> - circuit breakers around every external dependency, token-guarded atomic locks, self-healing background jobs, and Site Health diagnostics<\/li>\n<\/ul>\n\n<h4>Modern SEO &amp; UX<\/h4>\n\n<p>Translation-aware features generic SEO plugins can't provide: Content-Language HTTP header, <code>data-nosnippet<\/code> guard for fallback pages, and opt-in Speculation Rules prerender and View Transitions for switching between languages. Auto-detects and integrates with 20+ plugins and themes, including WooCommerce, Elementor, ACF, Meta Box, Pods, all major SEO plugins, Gravity Forms, and the Blocksy, Kadence, and Neve themes.<\/p>\n\n<h4>For developers<\/h4>\n\n<p>200+ action\/filter hooks, a full REST API, WP-CLI commands, and a documented addon system. A PHP helper API lets you translate a string or render a block of markup in any language from your own code. Every internal primitive is <code>@api<\/code> and semver-stable across 1.x. Multisite-ready. Full docs at <strong>https:\/\/perflocale.com\/docs\/<\/strong>.<\/p>\n\n<p><strong>Try it without installing anything.<\/strong> Open a throwaway WordPress site in your browser with PerfLocale already active: https:\/\/playground.wordpress.net\/?blueprint-url=https:\/\/perflocale.com\/blueprint.json<\/p>\n\n<h4>Where to go next<\/h4>\n\n<ul>\n<li><strong>Set-up guide<\/strong> \u2014 a fresh install to a translated post with a working switcher: https:\/\/perflocale.com\/docs\/getting-started\/<\/li>\n<li><strong>Switching from another plugin<\/strong> \u2014 importers for WPML, Polylang and TranslatePress: https:\/\/perflocale.com\/docs\/migration\/<\/li>\n<li><strong>How it compares<\/strong> \u2014 PerfLocale against WPML, Polylang and TranslatePress: https:\/\/perflocale.com\/compare\/<\/li>\n<li><strong>WooCommerce<\/strong> \u2014 products, variations, currencies, stock and order emails: https:\/\/perflocale.com\/docs\/woocommerce\/<\/li>\n<li><strong>Multisite<\/strong> \u2014 activation, per-site languages and background jobs across a network: https:\/\/perflocale.com\/docs\/multisite\/<\/li>\n<li><strong>Something not working?<\/strong> \u2014 symptom-first troubleshooting: https:\/\/perflocale.com\/docs\/troubleshooting\/<\/li>\n<li><strong>Hooks reference<\/strong> \u2014 every action and filter: https:\/\/perflocale.com\/docs\/hooks\/<\/li>\n<li><strong>REST API and WP-CLI<\/strong> \u2014 https:\/\/perflocale.com\/docs\/rest-api\/ and https:\/\/perflocale.com\/docs\/wp-cli\/<\/li>\n<li><strong>Source code<\/strong> \u2014 https:\/\/github.com\/perflocale\/perflocale<\/li>\n<\/ul>\n\n<h3>Privacy<\/h3>\n\n<p>PerfLocale is privacy-first by default. No tracking, no analytics, no visitor fingerprinting.<\/p>\n\n<ul>\n<li><strong>Cookie:<\/strong> one cookie, <code>perflocale_lang<\/code>, stores only the active language slug. <code>HttpOnly<\/code>, <code>Secure<\/code> on HTTPS, <code>SameSite=Lax<\/code>, 365-day default lifetime (filterable via <code>perflocale\/cookie_lifetime<\/code>). It can be turned off entirely (see \"Cookieless mode\" below) \u2014 language routing is URL-based and works without it.<\/li>\n<li><strong>Visitor IP:<\/strong> never logged or stored. The optional GeoIP-redirect feature (disabled by default) ships with no lookup provider and no endpoint, so out of the box it sends the IP nowhere. If you wire a source yourself through the <code>perflocale\/geo\/lookup_country<\/code> or <code>perflocale\/geo\/providers<\/code> filter, the IP is passed to that source once per first visit to resolve a country code; the country code is then cached server-side (24 hours by default) under a salted, non-reversible key - an HMAC-SHA256, keyed with the site's auth salt, of the IP after <code>wp_privacy_anonymize_ip()<\/code> has zeroed the host bits - never the raw IP or any value reversible to it.<\/li>\n<li><strong>WordPress Privacy API integration:<\/strong> Tools \u2192 Export Personal Data and Tools \u2192 Erase Personal Data both work. The eraser zeroes <code>created_by<\/code> on the background jobs the data subject dispatched and deletes their per-user UI-state meta \u2014 returning <code>items_removed<\/code>\/<code>items_retained<\/code> counts. The same flow runs on the admin <code>delete_user<\/code> path. Full detail in the docs.<\/li>\n<li><strong>Consent gating:<\/strong> the <code>perflocale\/privacy\/consent_given<\/code> filter lets any consent-management plugin (Cookiebot, Complianz, OneTrust, etc.) hold back PerfLocale until a visitor has consented. When the filter returns false, the <code>perflocale_lang<\/code> cookie is not set, and the GeoIP and browser-language redirects do not run (no outbound request is made).<\/li>\n<li><strong>Cookieless mode:<\/strong> PerfLocale \u2192 Settings \u2192 URL &amp; Routing \u2192 \"Language Cookie\" turns the <code>perflocale_lang<\/code> cookie off entirely \u2014 no consent-management plugin required. URL-based language routing keeps working; you only lose \"remember my language\" on non-prefixed URLs.<\/li>\n<li><strong>Suggested privacy-policy text:<\/strong> auto-registered via <code>wp_add_privacy_policy_content()<\/code>. The sections shown adapt to which features are enabled \u2014 GeoIP wording only appears if GeoIP is on, MT wording only appears if MT is on.<\/li>\n<\/ul>\n\n<p>Full technical detail: https:\/\/perflocale.com\/docs\/privacy\/<\/p>\n\n<h3>External Services<\/h3>\n\n<p>PerfLocale can optionally connect to external services for machine translation. All external service calls are <strong>disabled by default<\/strong> and require explicit user configuration (entering an API key and enabling the feature in settings). No data is sent to any external service without your action. The hosted providers below are contacted over HTTPS; for self-hosted or user-configured endpoints (LibreTranslate, agency, webhooks) use an HTTPS URL.<\/p>\n\n<p>The GeoIP-redirect and WooCommerce exchange-rate features ship with <strong>no<\/strong> provider and contact <strong>no<\/strong> service on their own: they call nothing unless you wire a source yourself through the <code>perflocale\/geo\/lookup_country<\/code> \/ <code>perflocale\/geo\/providers<\/code> and <code>perflocale\/woocommerce\/exchange_rates_fetched<\/code> \/ <code>perflocale\/woocommerce\/exchange_rate_providers<\/code> filters. If you connect one, disclosing that service is your responsibility as the site owner.<\/p>\n\n<p>API keys for the providers below can be supplied via an environment variable, a <code>wp-config.php<\/code> constant, or the admin Settings field (see the \"Can I keep API keys out of the database?\" FAQ).<\/p>\n\n<h4>Machine Translation<\/h4>\n\n<p>When you enable machine translation and configure an API key in PerfLocale \u2192 Settings \u2192 Addons \u2192 Machine Translation, the plugin sends the text you ask it to translate to the selected provider, together with source\/target language codes and your API key. That text is: post titles, content and excerpts; taxonomy term names and descriptions; interface strings listed on the Strings screen (which can include strings registered by other plugins and themes) when you use its machine-translation controls; and, when meta translation is enabled, registered meta values such as SEO titles and descriptions or custom text fields. It is sent when you click \"Machine Translate\", run a bulk or site-wide translation, translate via WP-CLI or the REST API, or enable auto-translate on publish. Each provider below receives exactly that data, and only for the actions just listed. The provider API additionally defines a connection-test call that sends only your API key (no post content) so an add-on can verify credentials; no screen, WP-CLI command or REST route in PerfLocale itself invokes it.<\/p>\n\n<ul>\n<li><strong>DeepL<\/strong> - api.deepl.com \/ api-free.deepl.com. Commercial neural-translation API (free and paid tiers). Receives the text, language codes and API key described above.\n<a href=\"https:\/\/www.deepl.com\/en\/pro-license\">Terms of Service<\/a> | <a href=\"https:\/\/www.deepl.com\/en\/privacy\">Privacy Policy<\/a><\/li>\n<li><strong>Google Cloud Translation<\/strong> - translation.googleapis.com. Google's paid cloud translation API. Receives the text, language codes and API key described above.\n<a href=\"https:\/\/cloud.google.com\/terms\">Terms of Service<\/a> | <a href=\"https:\/\/policies.google.com\/privacy\">Privacy Policy<\/a><\/li>\n<li><strong>Microsoft Azure Translator<\/strong> - api.cognitive.microsofttranslator.com. Microsoft's paid cloud translation API. Receives the text, language codes and API key described above.\n<a href=\"https:\/\/azure.microsoft.com\/en-us\/support\/legal\/\">Terms of Service<\/a> | <a href=\"https:\/\/www.microsoft.com\/en-us\/privacy\/privacystatement\">Privacy Policy<\/a><\/li>\n<li><strong>LibreTranslate<\/strong> - self-hosted or user-configured URL. Open-source translation server you host yourself or point at an instance you trust; the plugin calls no hard-coded LibreTranslate endpoint. Receives the text and language codes described above at the URL you configure. Terms of service and privacy policy are governed by the LibreTranslate instance you configure; the AGPL-3.0 linked below is the governing license of the software itself.\n<a href=\"https:\/\/github.com\/LibreTranslate\/LibreTranslate\/blob\/main\/LICENSE\">Terms (AGPL-3.0 License)<\/a> | <a href=\"https:\/\/github.com\/LibreTranslate\/LibreTranslate\">Source Code<\/a><\/li>\n<li><strong>WordPress AI Client<\/strong> - no hard-coded endpoint; delegated to WordPress core. When you select the \"WP AI Client\" provider on WordPress 7.0+ (or a host that ships the AI Client feature plugin), PerfLocale builds a short translation prompt (the text described above plus source\/target language codes) and hands it to WordPress core's <code>wp_ai_client_prompt()<\/code> function. PerfLocale itself makes no outbound HTTP request for this provider: WordPress core (and whichever AI provider you configured under core's AI Connectors settings) performs the network call. The data sent, the destination, and the governing Terms of Service \/ Privacy Policy are therefore those of the AI provider you configured in WordPress core, only for the actions listed above.\n<a href=\"https:\/\/make.wordpress.org\/ai\/2025\/07\/17\/ai-building-blocks\/\">WordPress AI Building Blocks<\/a><\/li>\n<\/ul>\n\n<h4>External Translation Agency<\/h4>\n\n<p>When you configure an external agency URL in PerfLocale \u2192 Settings \u2192 Addons \u2192 Machine Translation, the plugin sends post content to the configured endpoint for human or agency translation:<\/p>\n\n<ul>\n<li><strong>Custom Agency Endpoint<\/strong> - user-configured URL. A webhook endpoint you configure (use an HTTPS URL) to send post content to a human translator or translation agency for offline processing; the destination is entirely under your control and the plugin calls no hard-coded endpoint. The plugin sends post text, source\/target language codes, and a unique request ID, only when you submit a translation request; the agency must return the translated text in the immediate response. Terms of service and privacy policy are governed by the agency whose endpoint you choose to configure; review their public policies before sending post content.<\/li>\n<\/ul>\n\n<h4>Webhooks<\/h4>\n\n<p>When you register webhooks via the PerfLocale REST API, the plugin sends event notifications to your configured webhook URLs when translations are created, updated, or content changes:<\/p>\n\n<p>(loopback, private-network and credential-bearing destinations, and ports outside WordPress's own allowlist of 80, 443 and 8080, are rejected at validation time, and the refusal names the rule that was broken; on multisite, registering one requires network-administrator permissions) Endpoints you register via the PerfLocale REST API to receive translation-lifecycle notifications (use HTTPS URLs); the destinations are entirely under your control and the plugin calls no hard-coded URL. The plugin POSTs the event type, translation data (post IDs, language codes, status), and a timestamp whenever a translation is created, updated, or otherwise changes, and signs each payload with HMAC-SHA256 when a shared secret is configured. Terms of service and privacy policy are governed by whatever destination you register; review the operator's public policies before registering the URL.<\/p>\n\n<p>PerfLocale can also publish a read-only public REST endpoint for edge runtimes (<code>\/wp-json\/perflocale\/v1\/config<\/code>). It is served by your own site, makes no outbound third-party request and sends no data anywhere - see the \"Does PerfLocale expose anything to edge workers?\" FAQ for the full description.<\/p>\n\n<!--section=installation-->\n<p><strong>Permalinks:<\/strong> pretty permalinks (<strong>Settings \u2192 Permalinks<\/strong> set to anything other than \"Plain\") are recommended \u2014 with them WordPress guarantees that language-prefixed URLs such as <code>\/de\/\u2026<\/code> reach WordPress on every server. Plain permalinks also work (URLs become <code>\/de\/?p=123<\/code>) as long as the server routes all paths to WordPress, which nginx configurations and Apache setups with the standard WordPress <code>.htaccess<\/code> block do. Subdomain and per-domain URL modes carry the language in the hostname and work with any permalink setting. For servers that do not route unknown paths to WordPress, query-parameter mode (URLs become <code>example.com\/page?lang=de<\/code>, selectable under <strong>PerfLocale \u2192 Settings \u2192 URL &amp; Routing<\/strong>) works with any permalink structure \u2014 including Plain \u2014 on every server, keeping clean URLs for the default language. Site Health reports the exact status for your server.<\/p>\n\n<p><strong>Data exports and your web server (nginx and Caddy users, please read).<\/strong> Exports are written to <code>wp-content\/uploads\/perflocale\/exports\/<\/code> and are downloaded through an authenticated, nonce-checked admin link that deletes the file as soon as it is served. The directory also gets a <code>Deny from all<\/code> .htaccess \u2014 but <strong>only Apache and LiteSpeed honour .htaccess. nginx and Caddy ignore it.<\/strong> On those servers an export stays fetchable by its exact URL until it is downloaded or swept, so add an explicit rule:<\/p>\n\n<p>nginx:<\/p>\n\n<pre><code>location ~* \/wp-content\/uploads\/perflocale\/exports\/ { deny all; return 404; }\n<\/code><\/pre>\n\n<p>Caddy:<\/p>\n\n<pre><code>@perflocale_exports path \/wp-content\/uploads\/perflocale\/exports\/*\nrespond @perflocale_exports 404\n<\/code><\/pre>\n\n<p><em>Tools \u2192 Site Health<\/em> tells you which situation you are in: PerfLocale writes a temporary random file into that directory, requests it over HTTP, and reports a <strong>critical<\/strong> result if the server hands it back. Nothing to configure \u2014 just look at the check after your first export.<\/p>\n\n<ol>\n<li>Upload the <code>perflocale<\/code> folder to <code>\/wp-content\/plugins\/<\/code><\/li>\n<li>Activate PerfLocale through the <strong>Plugins<\/strong> menu in WordPress<\/li>\n<li>Go to <strong>PerfLocale \u2192 Languages<\/strong> and add your languages<\/li>\n<li>Set one language as the default<\/li>\n<li>Go to <strong>PerfLocale \u2192 Settings \u2192 URL &amp; Routing<\/strong> and choose your URL structure (subdirectory, subdomain, per-language domain, or query parameter)<\/li>\n<li>Start translating - click the language badges next to any post, page, or term to create translations<\/li>\n<\/ol>\n\n<h4>Quick Start<\/h4>\n\n<ol>\n<li><strong>Add languages<\/strong>: Go to PerfLocale \u2192 Languages. Add English as default, then add Bulgarian, German, etc.<\/li>\n<li><strong>Translate a post<\/strong>: Edit any post. In the PerfLocale meta box, click a language badge to create a translation. Edit the translation in the standard WordPress editor.<\/li>\n<li><strong>Add the language switcher<\/strong>: Add the \"Language Switcher\" block to any template or page, drop the Language Switcher widget into a widget area, or use the <code>[perflocale_switcher]<\/code> shortcode. To append it to a classic theme menu, tick the menu location under <strong>PerfLocale \u2192 Settings \u2192 Language Switcher \u2192 Append to classic menus<\/strong> (developers can also control it with the <code>perflocale\/switcher\/add_to_menu<\/code> filter).<\/li>\n<li><strong>Configure SEO<\/strong>: Go to PerfLocale \u2192 Settings \u2192 SEO. Enable hreflang tags and select your SEO plugin for automatic integration.<\/li>\n<li><strong>Set up machine translation<\/strong> (optional): Enable Machine Translation on the PerfLocale \u2192 Addons screen, then go to PerfLocale \u2192 Settings \u2192 Addons \u2192 Machine Translation. Enter your API key for DeepL, Google, or Microsoft, and enable auto-translate on publish. For production \/ staging deployments you can also supply API keys via environment variables (e.g. <code>PERFLOCALE_DEEPL_API_KEY<\/code>) or <code>wp-config.php<\/code> constants of the same name \u2014 env wins over constant wins over the database value, matching WordPress 7.0's AI Connectors source-priority pattern. See the API Keys documentation page for the full list of supported names.<\/li>\n<\/ol>\n\n<!--section=faq-->\n<dl>\n<dt id=\"does%20perflocale%20slow%20down%20my%20site%3F\"><h3>Does PerfLocale slow down my site?<\/h3><\/dt>\n<dd><p>Very little \u2014 performance is a core design goal. The plugin's own code stays a small fraction of total page time, with three layers of caching so most requests never touch the database. Larger sites \u2014 many translated posts across many languages \u2014 benefit from running a persistent object cache like Redis. PerfLocale is fully compatible with page-cache plugins (WP Super Cache, W3 Total Cache, LiteSpeed Cache, etc.); cached pages already include the translated output from the request that filled the cache. Real-world page speed depends mostly on your theme, hosting, and other plugins.<\/p><\/dd>\n<dt id=\"does%20it%20work%20with%20woocommerce%3F\"><h3>Does it work with WooCommerce?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale includes a deep WooCommerce integration: translate products, variations, categories, and attributes. Inventory (stock, SKU, weight, dimensions) syncs automatically across language variants. Multi-currency support, with exchange rates supplied by a provider your site registers via filter. Order emails are sent in the customer's language. The mini-cart, cart, and checkout all display correctly in every language.<\/p><\/dd>\n<dt id=\"does%20it%20work%20with%20page%20builders%3F\"><h3>Does it work with page builders?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale integrates with Elementor, Beaver Builder, Bricks Builder, Oxygen Classic, and Oxygen 6.0. Each builder's content is registered as translatable meta, and dedicated Language Switcher widgets\/elements are provided for Elementor, Beaver Builder, and Bricks \u2014 in Oxygen (Classic or 6.0) use the <code>[perflocale_switcher]<\/code> shortcode in a Code Block or Shortcode element.<\/p><\/dd>\n<dt id=\"how%20do%20i%20translate%20patterns%20and%20block-theme%20menus%3F\"><h3>How do I translate patterns and block-theme menus?<\/h3><\/dt>\n<dd><p>It depends on which kind of pattern you used, and the two look identical in the editor.<\/p>\n\n<p>A pattern you insert from the inserter is <strong>copied into the page<\/strong>, so its text is simply part of that page \u2014 translate the page under PerfLocale \u2192 Translations and the text comes with it. (This is also why the theme's pattern strings on the Strings screen don't apply to it: those are used when the theme renders the pattern file itself, while the copy in your page is plain HTML.)<\/p>\n\n<p>A <strong>synced<\/strong> pattern is stored once and the page only holds a reference to it, so there is nothing in the page to translate. Enable \"Patterns\" under <em>Advanced content types<\/em> at PerfLocale \u2192 Settings \u2192 Translation, then translate each pattern like any other post; PerfLocale serves the right one per language, including patterns nested inside a Group.<\/p>\n\n<p>Block-theme menus work the same way. Menu link addresses are already translated without any setting; enable \"Navigation Menus\" in the same place to translate the visible labels too. In both cases, if a translation is missing or still a draft the original renders \u2014 never a blank space.<\/p><\/dd>\n<dt id=\"how%20do%20i%20translate%20the%20site%20title%20and%20tagline%3F\"><h3>How do I translate the site title and tagline?<\/h3><\/dt>\n<dd><p>Go to PerfLocale \u2192 Strings and translate them like any other string \u2014 they are listed there as \"Site Title\" and \"Tagline\". There is nothing to switch on, and no scan to run: they are registered automatically when the plugin is updated or activated.<\/p>\n\n<p>Your original title and tagline are never overwritten. PerfLocale serves the translated text on the front end only, and refuses any attempt to write a translated value back over the original.<\/p><\/dd>\n<dt id=\"can%20i%20migrate%20from%20wpml%2C%20polylang%2C%20or%20translatepress%3F\"><h3>Can I migrate from WPML, Polylang, or TranslatePress?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale includes built-in migration tools for all three plugins. PerfLocale refuses to run while WPML, Polylang or TranslatePress is active, so deactivate the old plugin first \u2014 its data stays in the database \u2014 then go to PerfLocale \u2192 Settings \u2192 Export &amp; Import and use the Migration section. All migrations run in batches with transaction safety - if anything fails, your data is rolled back.<\/p><\/dd>\n<dt id=\"does%20it%20support%20rtl%20languages%3F\"><h3>Does it support RTL languages?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale detects the text direction from the language configuration and sets the correct <code>dir=\"rtl\"<\/code> attribute on the HTML element. The language switcher and admin UI work correctly with RTL languages like Arabic and Hebrew.<\/p><\/dd>\n<dt id=\"is%20it%20compatible%20with%20caching%20plugins%3F\"><h3>Is it compatible with caching plugins?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale works with all major caching plugins (WP Super Cache, W3 Total Cache, LiteSpeed Cache, WP Rocket). Each language version has its own URL, so page caches naturally separate content by language. Any response whose language was decided by something other than the URL \u2014 a GeoIP or browser-language redirect, a returning visitor's language cookie \u2014 is automatically marked uncacheable (via WordPress' <code>nocache_headers()<\/code>), so one visitor's language can never be cached and served to everyone. On an edge or server cache (Varnish, nginx fastcgi_cache) make sure Cache-Control is honoured or those responses are excluded. Note that enabling GeoIP \/ browser redirection makes default-language entry URLs uncacheable by design \u2014 a page cache serving them from cache would skip the redirect entirely; if you need both, use the bundled edge worker (assets\/js\/edge-helper.js) to route at the CDN instead.<\/p><\/dd>\n<dt id=\"can%20i%20use%20it%20on%20multisite%3F\"><h3>Can I use it on multisite?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale is multisite-compatible. Each site in the network has its own languages and translations. Static caches are properly scoped and reset on blog switches.<\/p><\/dd>\n<dt id=\"can%20i%20keep%20api%20keys%20out%20of%20the%20database%3F\"><h3>Can I keep API keys out of the database?<\/h3><\/dt>\n<dd><p>Yes. PerfLocale resolves every machine-translation API key from three sources in priority order: environment variable \u2192 <code>wp-config.php<\/code> constant \u2192 database setting (whichever has a non-empty value first wins). Set <code>PERFLOCALE_DEEPL_API_KEY<\/code>, <code>PERFLOCALE_GOOGLE_API_KEY<\/code>, <code>PERFLOCALE_MICROSOFT_API_KEY<\/code>, <code>PERFLOCALE_LIBRE_API_KEY<\/code>, <code>PERFLOCALE_LIBRE_URL<\/code>, <code>PERFLOCALE_AGENCY_URL<\/code> or <code>PERFLOCALE_AGENCY_API_KEY<\/code> in your container env or <code>wp-config.php<\/code> and the admin-side fields become read-only, showing which environment variable or <code>wp-config.php<\/code> constant supplies the value. Database backups, exports, and SQL dumps then never contain the secret.<\/p><\/dd>\n<dt id=\"what%20happens%20to%20my%20translations%20if%20i%20uninstall%20the%20plugin%3F\"><h3>What happens to my translations if I uninstall the plugin?<\/h3><\/dt>\n<dd><p>By default nothing is lost: uninstalling removes the plugin's roles, capabilities, scheduled tasks, and caches, but keeps all translations, languages, and settings in the database so a later re-install picks up exactly where you left off. If you want a complete removal instead, enable \"Delete all plugin data when uninstalling\" in PerfLocale \u2192 Settings \u2192 Advanced before uninstalling \u2014 then every plugin table and option is deleted. Your posts and pages (including translated ones) are always preserved as normal WordPress content.<\/p><\/dd>\n<dt id=\"does%20perflocale%20expose%20anything%20to%20edge%20workers%3F\"><h3>Does PerfLocale expose anything to edge workers?<\/h3><\/dt>\n<dd><p>When you enable Edge Worker Integration (PerfLocale \u2192 Settings \u2192 Advanced), the plugin publishes a single public REST endpoint that edge runtimes (Cloudflare Workers, Vercel Edge, Netlify Edge, AWS Lambda@Edge) can read to pre-route visitors before the request ever hits PHP:<\/p>\n\n<ul>\n<li><code>GET \/wp-json\/perflocale\/v1\/config<\/code> - returns the minimum routing + language metadata an edge worker needs: active language slugs and locales, URL mode (subdirectory \/ subdomain \/ domain \/ query), URL prefix type, default language, hide-default-prefix flag, excluded paths, detection order, the edge-hint header name (<code>X-PerfLocale-Lang<\/code>) and cookie name. Response includes <code>Cache-Control: public, max-age=300, s-maxage=3600, stale-while-revalidate=86400<\/code> plus an <code>ETag<\/code> so edges and browsers can revalidate cheaply with <code>If-None-Match<\/code> (304 on hit).<\/li>\n<\/ul>\n\n<p><strong>The response NEVER contains:<\/strong> machine-translation API keys, provider tokens, user data, internal post or term IDs, or any data that is not already observable from the rendered site (hreflang tags, language switcher, URL prefixes). That non-sensitive invariant is what justifies the public default. If you extend the payload via the <code>perflocale\/api\/config<\/code> filter, your additions must preserve this invariant.<\/p>\n\n<p>Public by default + filter-gated for restriction: site owners who want to restrict access (private staging sites, IP allowlists, Application-Password authentication, mTLS at a reverse proxy) hook the <code>perflocale\/edge_worker\/config_permission_callback<\/code> filter to return <code>false<\/code> or a <code>WP_Error<\/code> for unauthorised requests. Example:<\/p>\n\n<pre><code>add_filter( 'perflocale\/edge_worker\/config_permission_callback', fn () =&gt; current_user_can( 'manage_options' ) );\n<\/code><\/pre>\n\n<p>The plugin does not invent its own bearer-token or custom-header authentication scheme. Use WordPress' built-in primitives (Application Passwords for machine-to-machine auth, cookie + nonce for browser sessions, or a third-party JWT\/OAuth plugin) and gate via this filter \u2014 that way your edge-worker auth composes with the rest of your site's WP-REST authentication setup.<\/p><\/dd>\n\n<\/dl>\n\n<!--section=changelog-->\n<h4>1.0.4<\/h4>\n\n<p>Adds translation for the site title and tagline, synced patterns and block-theme navigation menus, and a PHP API for translating from your own code. Fixes a case where a translated site title could overwrite the original, a multisite call returning the wrong blog's name, and scheduled rendering windows resolving the wrong language. Full detail at https:\/\/perflocale.com\/changelog\/<\/p>\n\n<p><strong>Site title and tagline.<\/strong> Both are now translatable, and appear on the Strings screen as \"Site Title\" and \"Tagline\" rather than as raw option names. Nothing to switch on and no scan to run - they are registered when the plugin is activated or updated. Your stored title is never modified: the translation is served on the front end only, and a write-side guard refuses any attempt to save a translated value back over the original.<\/p>\n\n<p><strong>Synced patterns and block-theme menus.<\/strong> A synced pattern is stored once and referenced by every page that uses it, so translating the page could never reach its text. Enable \"Patterns\" under Advanced content types at Settings &gt; Translation and each pattern becomes translatable, including patterns nested inside a Group. Block-theme navigation menus work the same way: link addresses were already translated, and enabling \"Navigation Menus\" translates the visible labels too. If a translation is missing or still a draft the original renders - WordPress renders nothing at all for a reference it will not accept, and a blank space is worse than untranslated text.<\/p>\n\n<p><strong>A translated site title could overwrite the original.<\/strong> WordPress sanitises an option value before the filter that guards the write, so a title containing an apostrophe or ampersand arrived in a different form than the one the guard compared against - \"Alex's Bakery\" as \"Alex&#039;s Bakery\" - and the guard let the round trip through. The comparison now happens at WordPress's own sanitisation boundary.<\/p>\n\n<p><strong>The site-title strings were not registered when updating from wp-admin.<\/strong> The class was only loaded on front-end requests, so an operator whose first request after updating was a wp-admin page - the normal case - never got the strings registered at all, on that request or any later one. It now loads in every context.<\/p>\n\n<p><strong>Multisite: another blog's name.<\/strong> On a translated request, get_site() and get_blog_details() could return the calling blog's title for a different blog. The site-details filter now checks which blog it was handed.<\/p>\n\n<p><strong>Imposed rendering windows.<\/strong> Order emails and other internal rendering windows impose a language for part of a request. String translations, the site title and the helper's own accessors kept answering with the language the request arrived in, so an email could carry the wrong site title and a right-to-left order could render left-to-right. All of them now follow the imposed language.<\/p>\n\n<p><strong>Also fixed.<\/strong> Deleting a string through the plugin's own delete path left its translation links behind. A site installed below a directory named test, build or dist was skipped entirely by the string scanner, which reported nothing found - exclusions are now measured from the directory being scanned. Exchange-rate sync errors are recorded once per condition instead of on every scheduled run. An open Export\/Import panel on the Strings screen no longer sits behind the navigation tabs.<\/p>\n\n<p><strong>For developers.<\/strong> perflocale()-&gt;with_language( $lang, $callback ) runs a callback with another language imposed and restores it afterwards, even if the callback throws - the general primitive for rendering part of a request in another language. perflocale()-&gt;translate( $text, $lang, $args ) translates one registered string without touching request state. perflocale_t() takes an optional third $language argument. New filters: perflocale\/settings\/non_public_post_types and perflocale\/strings\/context_label.<\/p>\n\n<p>Translation of block template and template part content (full site editing) is not included in this release.<\/p>\n\n<h4>1.0.3<\/h4>\n\n<p>Fixes WooCommerce order emails going out in the wrong language, percent signs deleted from translated titles, exports publishing unreadable bytes, Site Health checks that never reached the dashboard, and the admin on phones and tablets. Full detail at https:\/\/perflocale.com\/changelog\/<\/p>\n\n<p><strong>WooCommerce order emails.<\/strong> Four faults. WordPress declines to switch language - recording nothing - when the target has no language pack or is already active; PerfLocale restored anyway, leaving the request in a language nobody chose. The restore also ran when the body finished, before WooCommerce builds the headers, attachments and plain-text half, so those came out wrong. The three notifications that go to the SHOP - new order, cancelled, failed - were rendered in the customer's language; they now follow the shop language. Renaming a language now also moves the order's language tag in the table High Performance Order Storage keeps it in.<\/p>\n\n<p><strong>Percent signs are no longer deleted from translated titles.<\/strong> WordPress's plain-text sanitiser strips every %XX sequence - right for a URL, wrong for a title: \"100%25 off\" was stored as \"100 off\". It applied wherever a translated title or excerpt was written: XLIFF import, the translation editor, machine translation and the TranslatePress migration, and it also stripped every HTML tag from imported excerpts.<\/p>\n\n<p><strong>Exports refuse to publish unreadable bytes.<\/strong> The JSON encoder replaced any byte it could not read as UTF-8 with a question mark - the worst option in a backup, because the damaged file encodes cleanly, passes every check and replaces the previous good one. It now stops and leaves the existing file alone. PO exports additionally report which strings will not survive a round trip.<\/p>\n\n<p><strong>Site Health checks never reached the dashboard.<\/strong> All 25 were registered for admin screens only, so WordPress's weekly site-health run - which happens on cron, where no admin screen exists - never saw one, and it overwrote the counts a manual visit had left. A site with missing tables, no default language, or exports readable over the web showed an issue count of zero. They now run in that pass; the three making a network request opt out, and none runs on the front end.<\/p>\n\n<p>Several also reported the wrong thing. The export-exposure check filed \"could not be checked\" as a passed test, worst on the hosts where exposure is most likely. The tables check looked at one table of nine, so a partial restore still read \"healthy\". Machine translation with no API key produced three green cards while every translation failed. The cron check called the recommended production setup a critical failure.<\/p>\n\n<p><strong>Webhooks.<\/strong> A delivery whose host could not be resolved was dropped with no retry and no trace; it is now retried like a failed request. The failure log had never been read by anything in the plugin - a new Site Health card reports deliveries abandoned in the last 24 hours. Registration now refuses URLs WordPress itself refuses at send time, and names the actual reason. <strong>On multisite, registering a webhook now requires network-administrator permissions<\/strong>, because a site administrator has manage_options without being able to install plugins. Listing and deleting are unchanged. If your network delegates registration to site administrators this is a breaking change; the perflocale\/webhooks\/register_capability filter restores the old rule.<\/p>\n\n<p><strong>Domain matching and number formatting.<\/strong> A Host header cased differently from the stored domain fell through to the default language, and a domain with non-ASCII characters never matched at all; both are now canonicalised using the encoder WordPress itself ships rather than an optional PHP extension. Separately, two languages the plugin ships are not recognised by the number formatter PHP 8.4 uses and the error was not caught - it now falls back, and the fallback stopped discarding decimals.<\/p>\n\n<p><strong>An empty switcher no longer draws an empty box.<\/strong> With \"hide current language\" on and untranslated languages hidden, an untranslated page has nothing to show - but the floating switcher still drew a blank panel over the page corner. Present since 1.0.0.<\/p>\n\n<p><strong>The admin was not usable on a touchscreen.<\/strong> Languages could only be reordered by drag-and-drop, which does not exist on touch; below 782px each row now has up and down buttons. The Translations list broke into unlabelled fragments on a phone; rows are rows again, with the checkbox and title pinned while the language columns scroll.<\/p>\n\n<p><strong>An integration could silently stop loading.<\/strong> PerfLocale remembered which integrations to load for twelve hours at a time. If that list was built while one of the plugins it integrates with had not finished loading, that integration stayed off until the note expired - and the Addons screen still called it active, because it only checks whether the other plugin is installed. The list is now worked out fresh each time.<\/p>\n\n<p><strong>Also:<\/strong> creating a language now validates its slug, as editing did.<\/p>\n\n<h4>1.0.2<\/h4>\n\n<p>Fixes translated content served in the wrong language on sites whose slugs are not Latin script, permanently corrupted slugs for long non-Latin names, fatal errors on servers built without an optional PHP extension, a PO import that could delete a language's translations, and a group of settings that could not be reached or were silently cleared. Full detail at https:\/\/perflocale.com\/changelog\/<\/p>\n\n<p><strong>Non-Latin slugs.<\/strong> WordPress stores a slug percent-encoded for every non-Latin script, and PerfLocale compared its lookups against that stored form. Two request shapes deliver the slug decoded and matched nothing: a query-string request such as <code>?category_name=\u30cb\u30e5\u30fc\u30b9<\/code>, which is what a plain-permalink site issues for every archive and page, and a URL carrying raw UTF-8 in the path. On those the visitor got the source language. Japanese, Chinese, Korean, Arabic, Hebrew, Russian, Greek, Thai and Hindi were affected, and so was any accented Latin slug such as the German \"\u00dcber uns\". Pretty-permalink sites were unaffected on the normal path. A pure-ASCII slug is returned unchanged, so nothing about an English or French site changes. Present since 1.0.0.<\/p>\n\n<p><strong>Corrupted slugs for long non-Latin names.<\/strong> Shortening a translated term slug cut by byte position and landed inside a percent-escape, so the slug became different characters rather than fewer, permanently. 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.<\/p>\n\n<p><strong>Fatal errors on servers missing an optional PHP extension.<\/strong> Building the language tag for the <code>Content-Language<\/code> header and the hreflang tags called a function WordPress ships no replacement for, on every request, so a server compiled without the ctype extension returned a fatal error instead of a page. XLIFF export and import reached for the XML extensions without checking. All now work without those extensions, or stop with a message naming the package to install. Site Health previously reported a critical failure for extensions nothing needed; it now lists only extensions that gate a real feature.<\/p>\n\n<p><strong>Machine Translation could not be switched on.<\/strong> Its settings tab was listed only once the feature was already enabled, and the checkbox that enables it lives inside that tab. On a default install there was no route to turn it on. Present since 1.0.0.<\/p>\n\n<p><strong>Three settings were silently cleared by unrelated saves.<\/strong> A tab only submits the controls it draws, but every key was read whether drawn or not, so a hidden control read as one the operator had unticked.<\/p>\n\n<p><strong>A PO import could delete a language's translations and put nothing back.<\/strong> Gettext parsing drops entries it cannot decode without raising an error, so a file saved as ISO-8859-1 read as valid but empty; in Replace mode that deleted everything and imported nothing. Non-UTF-8 files are now refused before anything is deleted. Exports were affected too: a carriage return made the whole file unparseable.<\/p>\n\n<p><strong>Excluded paths never matched a non-Latin path<\/strong>, and the two places that checked them disagreed, so an excluded <code>\/api<\/code> also excluded <code>\/apifoo<\/code> in one of them.<\/p>\n\n<p><strong>A refused settings save reported success.<\/strong> The database declines a value it cannot store rather than shortening it, so nothing changed and the screen still said it saved.<\/p>\n\n<p><strong>Language prefixes outside plain ASCII could not be read back.<\/strong> A locale such as <code>sr_RS@latin<\/code>, or one written in Cyrillic, was written into the URL correctly and then stripped by the reader.<\/p>\n\n<p><strong>WPForms confirmation messages are now translated<\/strong>, and <strong>XLIFF imports write once per post instead of once per field<\/strong> \u2014 an import of 40 posts went from 120 post writes to 40, byte-for-byte identical.<\/p>\n\n<p><strong>Behaviour changes worth knowing.<\/strong> An XLIFF import now creates one revision per post rather than one per field, and the save hooks fire once per post. If a plugin vetoes the write for a post, none of that post's fields are stored. <code>libretranslate.com<\/code> no longer skips the outbound-URL safety check. Machine-translation length caps now count characters rather than bytes, so the real limit matches the stated one for CJK, Arabic, Hebrew and Russian.<\/p>\n\n<h4>1.0.1<\/h4>\n\n<p>Security and reliability release. Updating is recommended for every site, and required for any site that uses the Translator role or Contact Form 7.<\/p>\n\n<p><strong>Security.<\/strong> 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, so the publish and delete capabilities that role deliberately withholds were bypassed. Status changes are now mapped to the target post type's own capabilities, exactly as WordPress core does. Drafting is unchanged.<\/p>\n\n<p><strong>Security.<\/strong> Translated Contact Form 7 forms rendered the form's stored configuration into the public page \u2014 recipient addresses, mail templates and headers included \u2014 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 instead. Note that a machine-translated form reverts to the source form markup until it is re-saved in Contact Form 7's editor.<\/p>\n\n<p><strong>Security.<\/strong> A new Site Health check reports whether your server actually protects the export directory. The plugin writes a <code>Deny from all<\/code> .htaccess beside every export, but nginx and Caddy ignore .htaccess entirely, and PHP cannot tell which server it is behind. Rather than assume, PerfLocale now places a temporary random file there, requests it over HTTP, and raises a critical result \u2014 with the exact nginx and Caddy snippet \u2014 if the file comes back. See the installation notes.<\/p>\n\n<p><strong>Security.<\/strong> Data exports are no longer guessable by URL: filenames now carry 32 characters of entropy instead of six. Exports are also published atomically, so a partially written file can never be served, and an export's filesystem path is hidden from REST callers who lack the import\/export capability. Nested credentials and URL user-info are now stripped recursively from exports rather than only at the top level.<\/p>\n\n<p><strong>Security.<\/strong> The same translation endpoint also accepted WordPress's internal post statuses. Setting a translation \u2014 or, for the site's default language, a source post \u2014 to <code>auto-draft<\/code> 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 workflow statuses added by other plugins are unaffected, and custom post types are judged by their own capabilities rather than by the built-in post ones.<\/p>\n\n<p><strong>Security.<\/strong> Machine translation requested through the plugin's Abilities integration bypassed the hourly per-user and site-wide translation limits, and the contention guard, that the REST endpoints enforce. On a site with Abilities enabled this meant a single account could spend translation credit without limit. All three entry points now share one admission check.<\/p>\n\n<p><strong>Security.<\/strong> The WordPress AI Client provider called the SDK's <code>generateText()<\/code> directly. WordPress applies <code>wp_supports_ai()<\/code> and the site-wide <code>wp_ai_client_prevent_prompt<\/code> 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.<\/p>\n\n<p><strong>Security.<\/strong> Machine-translation rate limiting now validates the shape of its stored counter rather than only its presence. A corrupted counter \u2014 from an object-cache collision or another plugin writing the same key \u2014 could previously slip past the hourly cap entirely, or turn a translation request into a fatal error.<\/p>\n\n<p><strong>Reliability.<\/strong> When the scheduler refuses a webhook retry, that refusal is now 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 also reported rather than silently skipped.<\/p>\n\n<p><strong>Reliability.<\/strong> A post list containing an entry that is not a post object \u2014 WordPress can hand one to its <code>the_posts<\/code> filters when a post is permanently deleted during a page load \u2014 caused a fatal error in the front-end translated-slug preload and in the translations column of the admin post list. Both now skip such entries and behave exactly as before for every real post.<\/p>\n\n<p><strong>Performance.<\/strong> 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, which bumped each translation's modified date, fired the save hooks other plugins listen to, and cost a full set of database writes per translation. Translations are now written only when a synced field really differs. Nothing about what gets synced has changed.<\/p>\n\n<p><strong>WooCommerce.<\/strong> 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 networks and per-domain networks are unaffected.<\/p>\n\n<p><strong>Background jobs.<\/strong> 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 they can no longer prove they own the file; the artifact is left to the age sweep instead. The failure log also no longer records the export path, filename or download token \u2014 only the job ID and a short hash.<\/p>\n\n<p><strong>REST API.<\/strong> A status that WordPress refuses for a post type now returns 400 <code>translation_status_rejected<\/code> instead of 500. Nothing is written either way; the previous response told clients the server had broken when the request was at fault.<\/p>\n\n<p><strong>Caching.<\/strong> New <code>perflocale\/cache\/purge_urls<\/code> 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. PerfLocale now names the URLs; wiring them to your cache is a three-line filter. See the hooks reference.<\/p>\n\n<p><strong>Security.<\/strong> Draft, pending, private and trashed translations no longer leak through Yoast breadcrumb structured data or the WooCommerce cart. Custom machine-translation endpoints are checked against IPv6 addresses as well as IPv4, and translation responses are size-capped. Credential masking in provider errors no longer depends on the credential containing a digit.<\/p>\n\n<p><strong>WooCommerce.<\/strong> Simultaneous purchases across language versions of the same product no longer lose a stock decrement. Each sale is now applied to the sibling languages as a relative change that 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 product lookup table WooCommerce uses for shop queries are now derived in the same operation that reads the quantity, so a translated product can no longer display a figure that a concurrent sale has already superseded. Exchange-rate syncing now merges the provider's response instead of replacing the whole stored set, so a partial response no longer wipes the currencies it omitted, and 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.<\/p>\n\n<p><strong>Content.<\/strong> Protected nested blocks keep their inner blocks. XLIFF export no longer strips emoji, rare CJK and other supplementary-plane characters. A failed table read during export now fails loudly instead of producing a valid-looking, silently incomplete backup.<\/p>\n\n<p><strong>Reliability.<\/strong> A failed string-translation move no longer deletes the source rows. A transient database error while building the link map is no longer cached as an authoritative empty result. Job locks can no longer be released or garbage-collected by a previous owner. Data import no longer flushes the entire object cache every 500 rows, which on sites with Redis or Memcached evicted every other plugin's cached data. The Action Scheduler integration \u2014 including the bridge that marks a job failed when its worker is killed \u2014 now actually registers; it previously never did.<\/p>\n\n<p><strong>Background jobs.<\/strong> Deleting a job now also removes the export file it produced, instead of leaving it in the uploads folder with no record identifying it. That cleanup no longer follows a symbolic link, so it cannot remove a file belonging to a different job, and it now verifies that 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.<\/p>\n\n<p><strong>Background jobs.<\/strong> 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 \u2014 so scheduling guards that relied on it could queue duplicate work. It now asks Action Scheduler directly. Recurring maintenance events now report whether the scheduler actually accepted them, and a refusal is logged with its reason instead of being indistinguishable from success. Site Health also now lists the machine-translation usage cleanup among the recurring events it checks, so all four are covered. 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 <em>other<\/em> plugins' stuck tasks. 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. When a scheduler refuses an event \u2014 a filter veto, a duplicate, or a queue error \u2014 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 one process handles more than one site.<\/p>\n\n<p><strong>Routing.<\/strong> Query-parameter URL mode now honours the URL Prefix Format setting: a site set to locale form serves <code>?lang=en-us<\/code> rather than <code>?lang=en<\/code>, everywhere the plugin writes a URL \u2014 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 instead of two that both answer 200.<\/p>\n\n<p><strong>Routing.<\/strong> A language could be made unreachable by its own slug. Language 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 \u2014 give one language the slug <code>de-de<\/code> and another the locale <code>de_DE<\/code>, and <code>\/de-de\/<\/code> served the second one. Real slugs now always win, whatever order the languages were created in.<\/p>\n\n<p><strong>REST API.<\/strong> Creating a language with a slug or locale that already exists returned HTTP 500 with a raw database error. It now returns 409 with <code>slug_exists<\/code> or <code>locale_exists<\/code>, so a client can tell \"already there\" from \"something broke\".<\/p>\n\n<p><strong>New filter.<\/strong> <code>perflocale\/url\/query_var<\/code> renames the query variable used by query-parameter URL mode, for sites where another plugin already owns <code>lang<\/code>. Register it from an mu-plugin and flush permalinks afterwards. See the hooks reference for details.<\/p>\n\n<p><strong>New filters.<\/strong> <code>perflocale\/jobs\/deduplicate_admission<\/code> controls whether an identical in-flight job blocks a new dispatch (default on). <code>perflocale\/breaker\/probe_lease_seconds<\/code> sets how long a single circuit-breaker probe holds its turn before another request may try. See the hooks reference for both.<\/p>\n\n<p><strong>Backup and restore.<\/strong> 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 left exactly as it was; previously the import deleted the old rows, committed everything that did land, and reported success with the failure listed among the notices. The same is true of a PO import run with replace: a refused translation now rolls the language back instead of leaving it part-replaced with an entry marked translated but empty. Settings, add-on settings, the disabled-add-on list and role grants carried in an import bundle are also undone when the tables roll back \u2014 they used to stay applied over data that had reverted.<\/p>\n\n<p><strong>Backup and restore.<\/strong> Every write to an export file is now 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 \u2014 the export is abandoned and your previous backup is left in place. A row that cannot be encoded also aborts the export instead of being written as nothing.<\/p>\n\n<p><strong>Background jobs.<\/strong> A worker that cannot record itself as running no longer runs the job anyway. Previously a failed status write left the job listed as queued while its work went ahead \u2014 translations created, provider credit spent, the completion hook fired \u2014 and the queued row stayed eligible to be picked up and run a second time. A job cancelled at the moment its worker starts is now caught too.<\/p>\n\n<p><strong>Background jobs.<\/strong> Dispatching the same job twice \u2014 a double-clicked button, a retried request, the same <code>--async<\/code> command run again \u2014 now returns the job already in flight instead of queueing a second one. Only an identical operation is folded in: different arguments, a different job type and the chunked site-translation chain all keep running in parallel as before.<\/p>\n\n<p><strong>Machine translation.<\/strong> The circuit breaker now counts a provider that answers with something unreadable. A proxy or captive portal returning HTTP 200 with an error page used to <em>clear<\/em> the failure count on every call, so the breaker could never trip no matter how long the outage lasted. When the breaker does open and its cooldown expires, exactly one request is now let through to test the provider rather than every request that arrives at once. A late failure can no longer republish a stale \"closed\" state over a breaker that just tripped, and corrupted breaker state cached by another plugin no longer causes a fatal error. The DeepL and Google providers also refuse a batch reply that does not have one translation per text sent, which otherwise shifted every later translation onto the wrong source string.<\/p>\n\n<p><strong>Privacy.<\/strong> Error text from the HTTP layer is now credential-masked before it is stored or shown, in both the provider error path and the webhook failure log. Only a site-local HTTP filter, custom transport or proxy could put a secret there, but the code no longer assumes none does.<\/p>\n\n<p><strong>WP-CLI.<\/strong> <code>wp perflocale import<\/code>, <code>po-import<\/code> and <code>network-import<\/code> now exit non-zero when the import reports errors. A malformed file previously printed a warning and then <code>Success:<\/code> with exit code 0, so a restore script could record a restore that never happened. <code>wp perflocale translate<\/code> no longer prints ten \"invalid synopsis part\" warnings on every successful run.<\/p>\n\n<p><strong>REST API.<\/strong> Malformed XLIFF \u2014 an empty body, XML that does not parse, a document declaring entities, or a target language the site does not have \u2014 now returns 400 <code>invalid_xliff<\/code> instead of 500. Genuine server faults still return 500. An XLIFF file that repeats the same unit identifier is also applied once instead of once per repeat; the result is identical and the import is far quicker.<\/p>\n\n<p><strong>Routing.<\/strong> Translated permalinks on non-Latin sites could be generated broken. WordPress percent-encodes non-ASCII slugs, so one Japanese character costs nine slug characters and an ordinary 26-character title becomes a 198-character slug. When two such slugs collided, PerfLocale trimmed one to make room for a <code>-2<\/code> suffix and the cut landed in the middle of a percent-escape \u2014 leaving a slug ending in something like <code>%e3%81%8<\/code>. Apache and nginx both reject a malformed escape in a URL path with a 400 error before WordPress runs, so that translation's permalink, and its entry in the language switcher, hreflang tags and sitemap, could not be opened at all. The trim is now character-aware, exactly as WordPress does it for post slugs. Existing slugs are untouched; re-save the affected translation to regenerate one.<\/p>\n\n<p><strong>Multisite.<\/strong> The notice explaining that PerfLocale cannot run alongside WPML, Polylang or TranslatePress now also appears in Network Admin. When the other plugin was network-activated, the person who activated it worked in Network Admin \u2014 where WordPress does not fire the hook the notice used \u2014 so they saw nothing at all, and the <code>wp perflocale migrate<\/code> command does not exist in that state either. The notice also now says that their existing translations stay in the database and can be imported after deactivating.<\/p>\n\n<p><strong>Add-ons.<\/strong> A failed add-on boot no longer leaves a permanent warning. WordPress replaces plugin files in place during an update, so a page request landing mid-write can see a half-written file and report a parse error \u2014 a problem that fixes itself on the very next request. PerfLocale recorded that permanently and showed it to every administrator until someone ran a WP-CLI command. The record is now retired as soon as the add-on boots successfully, and clearing an add-on's quarantine from the Add-ons screen clears its boot errors too. A failed migration or uninstall is still kept, because a successful boot says nothing about those. The recorded message also no longer contains the server's absolute file paths.<\/p>\n\n<p><strong>Behaviour changes worth knowing.<\/strong> On a site using query-parameter URLs with locale-form prefixes, the query value changes on upgrade and the old form now issues a permanent redirect \u2014 expect search engines and browsers to cache that redirect. If two of your languages collide on a slug-versus-locale form, the URL that was serving the wrong language starts serving the right one. 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. The <code>perflocale\/mt\/pre_translate<\/code> and <code>perflocale\/machine_translation\/after<\/code> hooks now carry the source object's own language rather than the site default. A save that changes none of the synced fields no longer writes to the translations, so <code>save_post<\/code>, <code>post_updated<\/code> and <code>transition_post_status<\/code> no longer fire for them on  &hellip;<\/p>","raw_excerpt":"Performance-first multilingual plugin. Translate posts, pages, products, taxonomies, strings, and slugs with 3-layer caching and 20+ integrations.","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin\/297618","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin"}],"about":[{"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/types\/plugin"}],"replies":[{"embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/comments?post=297618"}],"author":[{"embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wporg\/v1\/users\/alexgeorgiev"}],"wp:attachment":[{"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/media?parent=297618"}],"wp:term":[{"taxonomy":"plugin_section","embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_section?post=297618"},{"taxonomy":"plugin_tags","embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_tags?post=297618"},{"taxonomy":"plugin_category","embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_category?post=297618"},{"taxonomy":"plugin_contributors","embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_contributors?post=297618"},{"taxonomy":"plugin_business_model","embeddable":true,"href":"https:\/\/lmo.wordpress.org\/plugins\/wp-json\/wp\/v2\/plugin_business_model?post=297618"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}