Changelog

Release history and version notes

1.0.0 Initial release 2026-06-18
Everything below is in the first public release of PerfLocale. A performance-first multilingual plugin for WordPress. Translate posts, pages, products, taxonomies, strings, and slugs with a 3-layer cache (static → object cache → transients). Measured PHP wall-clock inside the plugin's own callbacks lands at about 1 ms median per request on a minimal reference install; a full WooCommerce store with three languages, large menus, and Redis is closer to 7-10 ms. Across 1,440 minimal-install samples — single-site and multisite, no-cache / cold-Redis / hot-Redis, six configurations — not one exceeded 5 ms. Sites with more than ~2,000 translation links should run a persistent object cache (Redis or similar) to stay under 5 ms. Real-world numbers depend on your theme and other plugins. Every feature follows WordPress standards - prepared SQL, escaped output, nonce-verified forms, capability-gated endpoints - with no third-party PHP libraries.
  • Core translation: posts, pages, any custom post type, categories, tags, custom taxonomies, and per-language URL slugs. Configurable fallback chains when a translation is missing (show_404, show_default, redirect_default).
  • URL routing: subdirectory (/en/), subdomain (en.example.com), or per-language domain modes. Language detection from URL, cookie, browser preference, or GeoIP (IPinfo, IPinfo Lite, ipapi.co, ipstack, ip-api.com). Self-healing rewrite rules rebuild automatically if language patterns go missing.
  • String translation: translate gettext strings from any plugin or theme without code changes. Two storage modes - pre-generated .l10n.php files for speed, or database mode with lazy-loaded gettext filters. Built-in scanner with MO/PO hint lookup. Settings → Performance → Regenerate Translation Files self-heals orphaned data (translations with missing translation_groups/translation_links rows) before writing files, and reports the reconnected count in the completion notice. Tools → Site Health detects the same orphan states and links to the regenerate action.
  • Language switcher: Gutenberg block (inline / simple / dropdown), shortcode, automatic nav-menu integration, and admin-bar switcher. WordPress Block Hooks auto-insertion (FSE themes only) places the switcher after the Site Title in block-theme headers by default — opt-out via Settings → Language Switcher, retarget via the perflocale/switcher/auto_insert_anchor filter, or rewrite the default attributes (e.g. force a compact dropdown in header context) via perflocale/switcher/auto_insert_attrs. Full ARIA listbox accessibility and keyboard navigation - role="listbox" + aria-labelledby on the panel, role="option" + aria-selected on each item, aria-haspopup="listbox" + aria-expanded + aria-controls on the trigger, aria-disabled on untranslated options, aria-current on nav-menu items; Enter / Space / ArrowDown opens + focuses option 1, ArrowUp opens + focuses the last option, arrows / Home / End move focus inside the panel (wrapping), a single printable character does first-letter type-ahead (mirrors native <select>), Esc closes the panel and returns focus to the trigger. Every dropdown option, the dropdown trigger button, inline / list / simple-mode option links, and the current-language span carry lang="<target-slug>" and dir="rtl" when the target language's text_direction is RTL - the visible text inside each option is in the TARGET language ("Français", "العربية") not the page language, so screen readers need lang to pronounce labels with the right phonemes and the browser needs dir="rtl" to render RTL labels right-to-left even on an LTR page. The Gutenberg block honors WordPress's align (left / center / right / wide / full), spacing (padding / margin), color, typography, and className block supports on the frontend - the server-side render passes through get_block_wrapper_attributes() so editor choices reach the page and themes' is-layout-constrained rules can't stretch the dropdown trigger to the full content-area width. Per-instance Dropdown Arrow control (single chevron, double-stacked chevrons, or none, with a perflocale/switcher/arrow_html filter for theme custom icons) and per-instance Trigger Label Format (inherit / native / english / both / slug - lets a header pill show "EN" while the dropdown lists full names) are available on every surface that exposes the switcher - Settings → Switcher, the floating Customizer switcher, Blocksy header + footer language-switcher components, Kadence Customizer, the Neve header builder component, and the Gutenberg block - and only render when Display Mode is Dropdown so the fields are hidden as dead UI for inline/simple modes. perflocale/switcher/panel_before + perflocale/switcher/panel_after filters inject HTML at the start / end of the dropdown panel (inside the listbox, around the options) for region groupings, search inputs, branding chrome, etc.; sanitised through kses_switcher() with an extensible allowlist via perflocale/switcher/kses_allowed_html. Dropdown CSS uses logical properties throughout (inset-inline-start, padding-block / padding-inline, margin-inline-start, …) so the panel anchors to the trigger's inline-start edge on LTR pages and inline-end on RTL pages without a separate RTL stylesheet; the JS horizontal-overflow flip reads getComputedStyle(panel).direction to mirror the same convention. Every dropdown colour, surface, spacing, timing, and z-index value is exposed as a --perflocale-dd-* CSS custom property with a hardcoded fallback (30+ vars) - themes recolour / resize the switcher by overriding the relevant vars instead of restating rules; @media (prefers-reduced-motion: reduce) disables cosmetic transitions for users who opt out at the OS level. Live panel repositioning while open: scroll (capture, passive) + resize + a ResizeObserver on the trigger button re-run the panel's positioning if the page reflows (sticky-header scrolls, viewport rotation, mobile browser chrome show/hide, web-font swap); listeners attach on open and detach on close so the cost is paid only while the panel is visible; vertical anchor flips above if there's no room below (accounting for the WP admin bar); horizontal anchor flips to inline-end if the inline-start anchor would push the panel past the viewport edge. Per-trigger click + keydown listeners (not one document-level handler firing on every event) - closest('.perflocale-switcher-block--dropdown') no longer runs for every click / keystroke on the page; a single document-level click listener is kept ONLY for outside-click-to-close. A MutationObserver picks up switchers added to the DOM after initial setup (Customizer previews, AJAX widgets, page-builder live previews, SPA navigations) so they receive the same listeners without integrators having to call a setup function. perflocale/switcher/option_content filter rewrites each option's inner HTML (flag + label) from a single hook fired on every render surface, for <bdi> wraps on mixed-direction labels, region badges, per-language icon overrides, etc.; return is sanitised through the switcher's kses allowlist before injection, including on the Gutenberg block render path that WordPress's do_blocks() doesn't otherwise sanitize. Filter returns from arrow_html / panel_before / panel_after / option_content are sanitised at the injection point via kses_fragment() — defense-in-depth so the block path matches the addon path's safety guarantees, with per-request sha1 memoisation in kses_switcher() so repeated identical fragments share one wp_kses parse, and a perflocale/switcher/sanitize_output filter lets sites that fully trust their callbacks opt out of wp_kses entirely while keeping the per-attribute esc_*() calls intact. Trigger uses :focus-visible (not :focus) so the keyboard focus ring shows only for keyboard / programmatic focus and not after a mouse click. Panel positioning math accounts for the --perflocale-dd-panel-offset custom property when checking viewport space so themes overriding the default 4 px gap to 12-16 px don't get edge-case flips on tight viewports where the panel would actually fit. Three bubbling CustomEvents for JS integrations: perflocale:switcher:open and perflocale:switcher:close fire after the panel state changes (including when a switcher is auto-closed because another opened, or by an outside click); perflocale:switcher:navigate fires BEFORE the browser follows a clicked option's URL and is cancelable — call event.preventDefault() to keep the user on the current page (unsaved-changes guards, pre-switch analytics with navigator.sendBeacon, custom redirect middleware, A/B testing tools). Events bubble so listeners can attach to the <nav> directly or to document and observe every switcher from one place; detail object carries nav / trigger / panel / option / slug / url as relevant. Per-instance Trigger Label Format + Dropdown Arrow are exposed on EVERY rendering surface — Gutenberg block, shortcode (trigger_format="…" + arrow_style="…"), PHP template tag, WP widget, Elementor / Beaver Builder / Bricks page-builder integrations, the Customizer floating switcher, and the four bundled theme addons (Blocksy header + footer, Kadence header, Neve header builder); empty / unset values fall through to the global Settings → Switcher defaults.
  • SEO: hreflang tags in both <link> head and HTTP Link response headers with x-default. When PerfLocale's own hreflang is enabled, the active SEO plugin's native hreflang output is suppressed automatically — wpseo_output_hreflang=false for Yoast, rank_math/frontend/disable_hreflang=true for Rank Math, aioseo_conflicting_shortcodes=[] for AIOSEO — so pages never carry duplicate <link rel="alternate"> tags or duplicate Link: headers when PerfLocale and an SEO plugin are both active. send_headers callback now uses explicit priority 1 so emission is deterministic alongside other plugins. language_attributes filter at explicit priority 11 so PerfLocale's <html lang>/dir injection is deterministic regardless of plugin load order vs default-priority SEO-plugin filters. WordPress sitemap integration with alternate URLs. New seo_sitemap_source setting (auto / core / plugin, default auto) chooses which sitemap tree carries the hreflang alternates: in auto mode the WP core sitemap injection is skipped when an SEO plugin is detected (since the SEO addon already injects into its own native sitemap), preventing search engines from seeing the same URL twice with different XML shapes; filterable via perflocale/sitemap/inject_into_core for unusual setups. All three SEO sitemap addons (Yoast, Rank Math, AIOSEO) register their entry-capture filters at PHP_INT_MAX so third-party URL rewriters at intermediate priorities can no longer break the URL-keyed map lookup. Schema.org JSON-LD enrichment (inLanguage + workTranslation) for Yoast, Rank Math, AIOSEO, SEOPress, Slim SEO, The SEO Framework. Content-Language HTTP header (BCP-47). data-nosnippet guard around fallback content. IndexNow push-indexing to Bing/Yandex with sibling-URL coordination. Speculation Rules prerender (WP 6.8+ Core API or standalone fallback on 6.4–6.7). View Transitions API crossfade on language switch with prefers-reduced-motion respected.
  • Machine translation: DeepL, Google, Microsoft, LibreTranslate, a custom External Agency endpoint, plus the WordPress AI Client (WP 7.0+) — translate via your host's already-configured AI provider (OpenAI / Anthropic / local Ollama / etc.) without provisioning a second key. Auto-translate on publish, bulk translation from the Translations admin page (small batches inline, larger ones queue as bulk_translate jobs visible under PerfLocale → Jobs), monthly character-limit tracking. Glossary enforcement for brand names and technical terms. Translation memory with configurable fuzzy-match threshold. SSRF-protected requests with SSL enforcement and exponential backoff retry. Opt-in AI quality scoring (hourly background job that asks the active AI provider to rate MT-translated rows 1-5 and flags low scorers for human review). Glossary suggest-as-you-type endpoint when the active provider is an AI client.
  • API key configuration: every machine-translation, GeoIP, and exchange-rate credential is resolved in env-var → wp-config.php constant → WordPress Connectors API (WP 7.0+, feature-detected) → database priority order. Lets containerised / version-controlled deployments keep secrets out of wp_options; multi-plugin sites get a single key-rotation point via Connectors. The Connectors layer is filter-extensible (perflocale/connectors/slug_map, perflocale/connectors/resolver) and stays a no-op on older WP versions.
  • Translation workflow: custom translator role, assignments with priorities and deadlines, and a clear status pipeline (Unassigned → Assigned → In Progress → In Review → Approved → Published). Email notifications in the recipient's admin language. Publish gate to prevent unapproved translations from going live.
  • Block editor experience: the Gutenberg link picker (Insert Link toolbar + ToC block) is now scoped to the language of the post being edited. An apiFetch middleware adds the open-post id to every /wp/v2/search call and the server-side filter resolves that id to a language, applies strict-mode language filtering on the WP_Query, AND re-prefixes the returned URL through UrlConverter::convert() so editors of a /de/ post only see DE pages with /de/ URLs - even on legacy data shapes where one post is registered for multiple languages in the same translation group (imported sites). Posts that are still untranslated fall back to the site default language for consistency. The Featured Image per Language metabox now only renders when an override would actually take effect for the open post: it hides languages that already have their own sibling translation post (their _thumbnail_id is the answer) AND the language the post itself is registered for (an override would just shadow the default Featured Image box), and the whole metabox is skipped entirely when no useful row remains - properly translated posts no longer carry an empty card. The metabox also mirrors WordPress core by checking both post_type_supports('thumbnail') and current_theme_supports('post-thumbnails', $post_type), and two new filters (perflocale/media/show_featured_image_panel and perflocale/media/featured_image_panel_languages) let themes / addons host the language overrides in their own UI. The Block translation sidebar's sibling button label was shortened (Translate from {lang} · {provider}) so it fits the 280-px Gutenberg sidebar without truncation on long source-language names.
  • WooCommerce: translate products, variations, categories, attributes, and attribute terms. Variation attribute names translated in cart, mini-cart, checkout, and order emails. Multi-currency with automatic exchange-rate sync (Frankfurter — which sources ECB reference rates — ExchangeRate-API, Open Exchange Rates, CurrencyFreaks, Fixer.io). Inventory sync mirrors stock / SKU / weight / dimensions across language variants. Order emails sent in the customer's checkout language and stored on the order for later re-sends.
  • 20+ auto-detecting integrations: WooCommerce; Elementor, Beaver Builder, Bricks, Oxygen Classic, 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.
  • Addon system for third-party authors: five capability interfaces (HasSchema for versioned database tables, HasUninstallTargets for declarative purge targets, HasCustomUninstall for external resource cleanup, HasVersionRequirement for skip-with-notice gating when an addon needs a newer PerfLocale than the running host, HasCardInfo for typed override of how the addon renders on the Addons admin page). Each addon lives in its own namespaced schema (wp_perflocale_addon_{addon_id}_{short_name}) with pattern-validated short names, strict uninstall-prefix enforcement, and manifest-backed orphan safety. Per-addon Enable / Disable toggle on the Addons admin page (perflocale_disabled_addons option, capped at 4 KiB to bound autoload cost); bundled addons protected by default and opt back in via the perflocale/addons/disable_bundled filter. Inline Settings form per card renders fields declared by get_settings_fields() (checkbox / text / textarea / number / select / password plus a 'custom' field type that hands rendering and sanitisation back to the addon via render_callback + sanitize_callback receiving \$addon_id + \$field_key + \$field_def for context; omitting the sanitize callback signals 'leave the stored value alone' so addon-managed state isn't wiped by a generic save) and persists them to a single autoloaded perflocale_addon_settings option keyed by addon ID, capped at 16 KiB per addon, writes lock-serialised via Lock::with() so concurrent saves can't lose either commit — read from anywhere via AddonSettings::get(\$addon_id, \$key, \$default) (round-trips stored null cleanly via array_key_exists, returns [] for corrupted DB entries instead of coercing a stray scalar into [0 => 'string']). Addons whose settings UI is too bespoke for field-by-field rendering (the bundled WooCommerce currency matrix is the canonical example) declare a public render_settings_subtab(\PerfLocale\Settings \$settings) method on the addon class and SettingsPage delegates the whole subtab body to it — keeps addon-specific UI / JS / AJAX wiring inside the addon directory instead of in the centralised SettingsPage. Addons whose settings live in the main perflocale_settings option (because the runtime + REST APIs already read from there — the bundled WooCommerce wc_* fields are the canonical example) declare each such field with 'storage' => 'global' so the framework skips auto-seeding a dead duplicate copy in perflocale_addon_settings, skips rendering the field in the auto-form, and routes `wp perflocale addon settings get/set/list` to the live value in perflocale_settings; a one-off cleanup migration strips any pre-existing dead-copy entries on next boot. Save + toggle gated by the perflocale_manage_addons capability (administrators only by default; Translator and Editor roles do not have it). AddonSettings writers and AddonRegistry::set_disabled return bool — false on rejection (invalid addon id per /^[a-z0-9_]{2,16}$/, over the size cap, or lock contention); the admin form surfaces rejections as red error notices naming the failure mode. Modern i18n helper Helper::load_addon_textdomain() uses Core's load_textdomain() on init priority 99 instead of the deprecated load_plugin_textdomain() that triggers _doing_it_wrong on WP 6.7+. Default settings auto-seeded on the addon's first successful boot from its get_settings_fields() declared defaults so AddonSettings::get() returns the documented default without callers having to pass one. AddonSettings::get_many() batch-reads N keys for one addon in a single in-memory cache lookup. Reliability hardening: third-party perflocale/addons/disable_bundled filter callbacks wrapped in try/catch so a throwing callback can't crash boot; AddonRegistry::get_disabled() defensively filters out invalid IDs from the option; AddonRegistry::set_disabled() writes lock-serialised via Lock::with() with DB re-read inside the lock to prevent lost commits under concurrent toggles. Operator visibility: Site Health Info adds rows for disabled / version-mismatched addon IDs and the current byte sizes of perflocale_addon_settings (per-addon cap 16 KiB) and perflocale_disabled_addons (total cap 4 KiB). WP-CLI gains wp perflocale addon doctor (one-shot health summary), enable / disable [--force], settings get (transparently reads from perflocale_settings for 'storage' => 'global' fields so the value reflects what the runtime actually uses) / settings set --type=string|bool|int|float|json (refuses 'storage' => 'global' fields with a pointer to wp option patch update perflocale_settings instead of silently writing to a dead duplicate copy) / settings list (shows addon-storage AND global-storage fields together with a `storage` column marking each row's source), and reset-quarantine — full operator parity with the admin UI for CI / deployment scripts. The list subcommand grows `disabled` + `booted` columns alongside `bundled` so operators can see lifecycle state without a separate doctor call, and honours --fields=id,name,disabled + --format=ids|csv|json|yaml|count for scripting. Three new action hooks for addon authors: perflocale/addon/seeded fires once after auto-seed for first-activation work; perflocale/addon/settings/before_save + after_save fire inside the write lock with $addon_id / $new_entry / $old_entry for cross-addon settings reactions (deferred via wp_schedule_single_event to avoid reentrancy). Auto-generated per-addon settings subtab under PerfLocale > Settings > Addons — each registered addon with user-editable get_settings_fields() gets its own dedicated subtab (the Manage button on the Addons listing card links there); the listing page itself no longer embeds inline settings forms. Conditional fields via show_if on get_settings_fields() entries — simple `[ 'driver' => expected_value ]` form (implicit AND) or nested `[ 'op' => 'AND'|'OR', 'rules' => [...] ]` with arbitrary nesting; evaluated server-side at render and client-side via auto-enqueued JS that scans the whole Settings page DOM (not just auto-generated addon forms), identifying driver inputs by either data-perflocale-field-name or standard HTML name= attribute. The bundled WooCommerce currency / exchange-rate conditional rows opt into the same pipeline this way, replacing the inline JS toggle that previously didn't always fire. Auto-generated addon subtab is hidden when the addon is disabled — re-enable from the Addons listing card and the subtab returns. ACF integration drops a dead `acf_auto_detect` checkbox that was declared but never consumed (auto-detection always ran). Built-in feature cards (Machine Translation, Translation Glossary, Translation Workflow) on the Addons listing now Enable/Disable via their underlying settings flag (mt_enabled / mt_glossary_enabled / workflow_enabled); the matching settings subtab under PerfLocale → Settings → Addons appears only when the feature is enabled. Disabling the WooCommerce addon from the Addons listing now hides its settings subtab too — the legacy built-in subtab list honours the disabled-addons option. WooCommerce currency / exchange-rate conditional rows (auto-sync, provider, interval, sync status) render even on single-language sites; the per-language exchange-rate matrix shows a clear 'add a second language' hint when there's only one. The matrix now auto-detects each row's currency code from the language's locale (pl_PL → PLN, en_GB → GBP, ja_JP → JPY, pt_BR → BRL, etc., for ~120 countries + 70 bare-locale fallbacks via the new PerfLocale\\WooCommerce\\LocaleCurrency helper) instead of dropping every newly-added language to the WooCommerce base currency. Auto-synced exchange rates now display correctly on page load and drive checkout/cart price conversion at runtime — previously the Sync Now button fetched rates and visually updated the inputs but the values reverted to 1.0 on refresh AND WooCommerce price conversion silently used 1.0 too, because the persisted rates lived in a dedicated perflocale_exchange_rates option that neither the render nor MultiCurrency consulted (display + runtime now both prefer the synced value when global wc_exchange_rate_auto is on; per-row manual_rate=true still pins a row to its manually-entered value as an opt-out). Addons admin page: Active tab no longer surfaces operator-disabled addons (status precompute resolves 'disabled' before 'active'); the tab nav now pairs Active with an Inactive tab (WordPress plugins-page convention) that surfaces everything not currently running regardless of why — the per-card badge still shows the specific reason ('Disabled' / 'Not active' / 'Not installed'). Built-in feature cards read as 'Disabled' instead of misleading 'Not installed' when their underlying Settings flag is off. Uninstall data-delete sweep now covers perflocale_disabled_addons + perflocale_addon_settings — previously left as orphan autoloaded options. Disable button now actually disables, for bundled addons too — old --force CLI flag and perflocale/addons/disable_bundled filter dropped; the safeguard had caused confusing 'booted AND disabled' doctor output. Quarantined-addon notice on the Addons admin page lists each quarantined addon with the exact wp perflocale addon reset-quarantine <id> command to clear it. The save handler now tolerates a throwing get_settings_fields() — third-party addons with bad data in their field declaration no longer fatal the admin save. Helper API hardening: perflocale()->current_language() memoised per request (invalidates on switch_blog + language add/update/rename/delete events) so locale() / slug() / name() / native_name() / display_name() / flag() no longer re-walk the DI container per call in template loops; safe_router() internal swallows DI throws so Helper methods called from mu-plugins / plugins_loaded:1 / mid-deactivation return safe defaults instead of fatal; new perflocale()->translations_many( $post_ids ) batch method issues two DB queries total regardless of input size; new is_translatable( $name, $type ) shortcut; new locale-aware format_number() + format_currency() via PHP intl NumberFormatter with graceful fallback; perflocale() global function explicitly @api semver-bound; extract_sprintf_placeholders + SPRINTF_TOKEN_PATTERN moved to PerfLocale\Util\SprintfTokens; 4 admin-form-only utilities re-marked @internal. New 27-helper-api.php regression scenario covers every public Helper method against live fixtures. Developer Toolkit doc at /docs/addon-system/developer-toolkit/ covers DI container, Lock, Breaker, background jobs, Helper API, settings (including get_many and auto-seed), i18n, version requirements, enable/disable, addon-to-addon dependencies, the full capability-interface table (now including HasCardInfo), the new CLI surface, and an end-to-end example addon.
  • Migration: bundled importers for WPML, Polylang, and TranslatePress. Preserves translation groups, string translations, term translations, and slug mappings. All three importers fetch source rows in tunable batches (filters perflocale/migration/wpml/batch_size, perflocale/migration/polylang/batch_size, perflocale/migration/translatepress/batch_size) so peak memory stays bounded on 50,000+ post sites. TranslatePress slug-import loop batch-primes the WP post cache via _prime_post_caches() before per-row get_post_field() lookups — saves ~4–9 seconds per 5,000-slug import on large TPP migrations. The importer's per-post batch transaction checks link_object()'s return and throws on failure so a single un-linked post rolls the whole batch back rather than inflating the imported counter past the actual link count on disk. wp_insert_post() callers across the migration and translation paths now pass $wp_error=true so DB-level failures (unique-violation, FK orphan) surface as real WP_Error objects instead of being conflated with the legitimate int 0 "couldn't insert" return — and a separate per-post checkpoint option (perflocale_trp_import_post_checkpoint) is now cleaned on full uninstall even when an interrupted import left it behind. Migration guides published for each source plugin.
  • Developer API: 200+ action and filter hooks (including 17 WordPress 7.0+ integration hooks for the AI Client provider, quality scoring, glossary suggest, Connectors API resolver, JS Abilities shim, and Block Hooks switcher auto-insertion), a full REST API (translations, machine translation, glossary, translation memory, XLIFF round-trip, webhooks, background jobs), WP-CLI commands (language management, bulk translation, string scanning, slugs, cache, glossary CSV, PO files, addons, health checks, migration, full-site export/import, multisite network export/import, background jobs control), WordPress Abilities API (WP 6.9+) with 6 opt-in abilities for AI tools, and a PHP helper API for theme developers.
  • Background processing: long-running operations (XLIFF data imports, data exports, bulk machine translation, WPML / Polylang / TranslatePress migrations, full-site string scans, multi-megabyte glossary CSV imports, AI quality scoring) move off the request path automatically. Action Scheduler when loaded, WP-Cron fallback. State persists in a dedicated `wp_perflocale_jobs` table with typed columns and indexes on uuid, (status, updated_at), (type, status), and created_by — no public pageview ever queries it. Short-lived advisory locks still use atomic `INSERT IGNORE INTO wp_options` for race-free row-level mutex semantics. Cooperative cancel-mid-flight, retry-with-exponential-backoff (5 attempts default), per-type concurrency cap, operator pause toggle, daily GC for stuck/orphaned jobs and stale lock rows, reactivation resume so jobs survive a deactivate/reactivate cycle. PerfLocale → Jobs admin page (with a single-use Download button for completed data-export jobs) + 'wp perflocale jobs' CLI + 5 REST endpoints. Per-job thresholds configurable via Settings → Performance → Background Thresholds. Per-blog isolation on multisite. Two bulk admin AJAX handlers (Create WooCommerce page translations, Create taxonomy translations) raise PHP time-limit only inside their nonce + manage_options-gated bodies; the value is filterable via perflocale/admin/bulk_time_limit (default 0 = no limit).
  • Multisite: per-subsite languages, translations, glossary, and workflow state. Auto-provision on new subsite creation. Clean per-subsite uninstall respecting each subsite's own delete_data_on_uninstall preference. Chunked network activation (filter perflocale/activation/chunk_size) so thousand-subsite networks don't exhaust memory.
  • Performance: three-layer cache (per-request static array → WP object cache → transients), batch-preloaded translation lookups on the_posts, conditional hook registration so disabled features add no listeners, LEFT JOIN queries instead of NOT IN subqueries for language filtering, language-specific WooCommerce cart fragment keys, and an autoloaded eager-link-map that holds every translation_link in one option on sites under ~2,000 links. Dedicated KEY context (context) index on wp_perflocale_strings so the StringsPage admin context-dropdown query becomes an index-only scan (EXPLAIN Extra=Using index) instead of a full-table sort — saves ~5–15 ms per admin load on sites with 1,000+ strings. Glossary fetch_entries SELECT narrowed to the three columns the matcher actually reads (source_term, target_term, case_sensitive), eliminating the unbounded notes TEXT column from every cached MT request (1.5–12 KB per hit). Measured plugin code time (PHP wall-clock inside PerfLocale callbacks): about 1 ms median on a minimal reference install (a full WooCommerce store with three languages and Redis is closer to 7-10 ms); across 1,440 minimal-install samples — single-site and multisite, no-cache / cold-Redis / hot-Redis, six configurations — not one exceeded 5 ms. Sites with more than ~2,000 translation links fall through to the per-link cache path and should run a persistent object cache to stay under 5 ms. Real-world numbers depend on theme + co-installed plugins; full methodology at perflocale.com/benchmarks/.
  • Security: prepared SQL throughout, escaped output at every render site, nonce verification on every form and AJAX request, capability checks on every privileged action, HTTPS + SSL verification on every external HTTP request. No third-party PHP libraries.
  • Privacy & accessibility: GDPR-integrated. Tools → Export Personal Data returns a user's translation-workflow assignments AND their PerfLocale admin-UI preferences (per-page list lengths and hidden-language column choices); paginated at 100 workflow rows per page so translators with thousands of assignments don't time out the request handler. Tools → Erase Personal Data runs five steps in one pass — anonymise workflow rows (zero assigned_to, status → unassigned; surrounding rows + notes preserved so other contributors' history isn't disturbed); scrub the data subject's email, login, display name, and (uniqueness-gated) first/last/full name out of every workflow row's notes field; scrub the same patterns out of every matching translation-memory row's source_text and target_text so user-generated content cached in TM doesn't retain PII; zero created_by on every active background-job row the user dispatched (worker cap re-validation then fails the job cleanly); delete the per-user UI-state meta. Returns integer counts in items_removed (UI-meta deletions + job-row anonymisations) and items_retained (workflow-row anonymisations + workflow-note scrubs + TM scrubs) so the data subject sees what survived in what form. The same scrubbing + anonymisation flow also fires on the admin-driven delete_user path so admins don't have to file a GDPR request to get a clean cleanup. WCAG 2.1 AA-aligned across the language switcher, admin UI, and View Transitions; axe-core audited on release. Admin UI accessibility pass: every Glossary form field carries explicit <label for>/<input id> pairs; every admin data table (Glossary, Assignments, Translations, Jobs) has a <caption class="screen-reader-text"> describing the table contents; bulk-action checkbox column headers use <th scope="col"> rather than <td scope="col"> so screen readers announce them as headers; required Languages form inputs carry aria-required="true" in addition to HTML5 required. Inline onclick= handlers in PHP (LanguagesPage Set Default / Delete confirms, SettingsPage clipboard-copy widgets, GlossaryPage delete confirm, StringsPage scanning busy state) replaced with delegated data-perflocale-confirm / data-perflocale-copy / data-perflocale-submit-busy data-attribute patterns wired through a shared assets/js/admin-actions.js — keeps PHP free of inline JS while preserving the same UX. Reliability hardening: LanguageRepository::delete() now wraps every dependent-table DELETE (translation_links, orphan-group GC, glossary source/target, workflow, translation_memory source/target, slug_translations, string_translations) in a single START TRANSACTION with ROLLBACK on any per-table failure — a script timeout, DB error, or dropped connection mid-cleanup no longer leaves the site with the languages row gone but tens of thousands of FK-orphan rows behind. TranslationGroupRepository::link_object() (the load-bearing primitive called from every translation create + the TranslatePress importer's batch transaction) checks all three of its internal wpdb->delete returns and rolls back on failure so the documented one-object-one-group invariant is no longer silently violated when a DELETE fails. StringTranslationRepository::delete_for_language() throws on DELETE failure instead of returning silent 0 so the outer cascade rolls back. StringRepository::register_setting_string() serialised per (domain, context) via Lock::with so two concurrent saves of the same setting can't both INSERT new rows with different hashes and dangle the second's migration. StringRepository::insert() checks wpdb->insert()'s return explicitly before reading insert_id (the documented mysqli-driver-dependent stale prior-id case from bulk_insert applies here too). MT per-user rate-limit fails CLOSED on lock-acquire miss with a new perflocale/mt/rate_limit_site filter (default 5000) capping site-wide MT requests per hour. Glossary scan endpoint serialised through Lock::with plus a cache-first short-circuit and force parameter so concurrent callers get cached candidates or 429+Retry-After instead of running parallel full-table scans. Object-cache (L2) flush on full uninstall: SiteCleanup::full_purge() iterates the canonical CacheManager::GROUPS list (8 plugin-owned groups) and calls wp_cache_flush_group() on each — without this, persistent Redis / Memcached entries lingered for up to 12 h past uninstall and could be served as ghost reads on the next install. Shared user-cap orphan sweep (SiteCleanup::sweep_orphan_user_caps()) extracted as a public static helper and called from BOTH plugin deactivation and full uninstall, so direct-grant perflocale_* capability entries no longer accumulate in wp_capabilities user-meta across deactivation/reactivation cycles. WordPress 7.0+ AI Client provider: WpAiClientProvider now targets the actual WP 7.0 wp_ai_client_prompt() fluent-builder surface. Previous releases checked for wp_ai_client() / wp_ai_generate_text() / wp_ai_complete() — none of which exist in WP 7.0 — so the provider stayed permanently hidden from the picker even when the API was present. The new detection mirrors wp_supports_ai() + function_exists('wp_ai_client_prompt') (so a host can disable AI per-request via WP_AI_SUPPORT or the wp_supports_ai filter and we respect it), then wraps the builder with usingTemperature / usingMaxTokens / usingProvider / usingSystemInstruction (each gated by method_exists for forward compatibility), and finalises with ->generateText(). WP 7.0 returns a WP_Error or the builder itself from __call when no underlying AI provider is configured (rather than throwing); the wrapper detects that shape and converts it to an actionable RuntimeException so the circuit breaker classifies it correctly and the operator gets a meaningful error message instead of a TypeError. Verified live against the actual wp-includes/ai-client.php shipped with WordPress 7.0. Disaster-recovery idempotency for WPML, Polylang, AND TranslatePress migrations: a new perflocale_migration_source_map table pins each source-plugin identifier (WPML trid, Polylang term_id, TranslatePress post_id|lang_id pair) to the translation_groups row PerfLocale created for it. The mapping is INSERTed inside the same transaction that creates the group via the existing create_group() BEGIN/COMMIT block — so a partial-failure crash mid-migration rolls back both rows together, never leaving a stale map entry. On retry (whether after a crash or a full DB restore), the importer looks up (migration_type, source_key) BEFORE calling create_group(). If a mapping exists, the existing group_id is reused and no duplicate is allocated. Previous releases re-allocated a duplicate translation_groups row on every re-import after a restore. String translations were already idempotent (REPLACE INTO on (string_id, language_id)); this extends the same guarantee to posts and terms. TranslatePress uses a different dedup pattern because it wp_insert_post()s new translation posts: the pre-check uses get_translation_in_language() against translation_links so re-import after a restore that lost the POST_CHECKPOINT_OPTION row no longer duplicates every translation post. Operator escape hatch: `wp perflocale migrate <source> --force-restart` clears the source-map for one importer before the run — use after a deliberate restore to a clean pre-migration backup. CLI logs the row-count cleared before the import begins. Migration cache invalidation lives in a shared MigrationCacheHelper class — all four jobs (WpmlMigrationJob, PolylangMigrationJob, TranslatePressMigrationJob, DataImportJob) call MigrationCacheHelper::flush_post_migration_caches() after success: resets TranslationGroupRepository static memos, deletes the autoloaded perflocale_eager_links_post/_term/_has_any_groups options, flushes L2 cache. Without that, long-running CLI/cron workers continued serving pre-import group memos, and post-restore re-imports saw stale eager-link rows. The MT quality-scoring background job's WP 7.0 feature-detection was a sibling of the AI Client bug: MtQualityScoreJob::is_supported() was checking for wp_ai_client() / wp_ai_generate_text() / wp_ai_complete() (functions that never existed in WP 7.0); fixed to use the same function_exists('wp_ai_client_prompt') && wp_supports_ai() detection as WpAiClientProvider. i18n hardening: 12 bare-text <option> labels in three admin dropdowns (text-direction LTR/RTL, MT-provider DeepL/Google Translate/Microsoft Translator/LibreTranslate, SEO-plugin Yoast SEO/Rank Math/All in One SEO/SEOPress/The SEO Framework/Slim SEO) are now wrapped in esc_html__( ..., 'perflocale' ). Surrounding options in the same select and the field labels above them were already wrapped — the inconsistency made gettext extraction skip the bare labels, so translators (e.g. Arabic, Polish, Russian) couldn't localise parenthetical / casing / transliteration conventions. POT regenerated; all 12 new strings appear in languages/perflocale.pot. Bounded data retention: every plugin-owned data store has a clear bound. perflocale_strings and the cascading perflocale_string_translations carry a last_seen_at timestamp touched by both the file scanner and register_setting_string(); a daily GC deletes rows whose last_seen_at is older than perflocale/strings/stale_retention_days (default 90 days), cascading to translations in the same DELETE, with perflocale/strings/manual_contexts whitelisting setting / workflow-email contexts that may be re-registered infrequently. A defensive orphan-sweep in the same daily window catches any string_translations row whose parent strings.id was deleted by a path that bypassed the cascade. perflocale_translation_memory is capped at perflocale/tm/gc_row_cap rows (default 100,000) by a weekly LRU eviction that deletes the lowest-scoring rows (usage_count ASC, updated_at ASC) down to perflocale/tm/gc_target_rows (default 90,000); misconfigured filter values are sanity-clamped so a typo can't wipe the table. Every retention knob is filterable; every GC pass emits perflocale/strings/gc_complete, perflocale/string_translations/orphans_swept, or perflocale/tm/gc_complete action hooks for monitoring integrations. Together with the existing daily orphan-group sweep on translation_groups, the weekly perflocale_mt_usage_gc for monthly counters, and the daily Lock::reap_expired() for stale locks, no plugin-owned data store can grow unbounded. PHPStan max-level static analysis enabled with phpstan-baseline.neon — future runs report [OK] No errors, surfacing only new findings.