- Security hardening and stricter permission checks.
- Behaviour change: a machine-translated Contact Form 7 form shows its source markup until it is re-saved in Contact Form 7's editor.
- WooCommerce: simultaneous purchases across language versions of the same product no longer lose a stock decrement. Each sale is applied to the sibling languages as a relative change the database performs atomically, so three shoppers buying three language versions of a stock-10 product at the same instant leave every version at 7 — previously two of those three sales could be lost, and the loss grew with the number of simultaneous orders. Stock status and the lookup table WooCommerce uses for shop queries are derived in the same operation that reads the quantity, so a translated product cannot display a figure a concurrent sale has already superseded.
- WooCommerce: exchange-rate syncing merges the provider's response instead of replacing the whole stored set, so a partial response no longer wipes the currencies it omitted. A response that fails validation now counts as a failure for the circuit breaker rather than resetting it. Bulgaria's suggested currency is now EUR.
- Content: protected nested blocks keep their inner blocks, and XLIFF export no longer strips emoji, rare CJK and other supplementary-plane characters.
- Content: a failed table read during export now fails loudly instead of producing a valid-looking but silently incomplete backup.
- Reliability: a failed string-translation move no longer deletes the source rows, and a transient database error while building the link map is no longer cached as an authoritative empty result.
- Reliability: job locks can no longer be released or garbage-collected by a previous owner, and the Action Scheduler integration — including the bridge that marks a job failed when its worker is killed — now actually registers. It previously never did.
- Performance: saving a post no longer rewrites its translations when nothing that is actually synced has changed. Every save used to issue one full update per translation regardless, bumping each translation's modified date, firing the save hooks other plugins listen to, and costing a full set of database writes per translation. Translations are now written only when a synced field really differs; what gets synced is unchanged. The perflocale/cache/flush_object purge signal still fires for each translation, so CDN integrations are unaffected.
- Reliability: a post list containing an entry that is not a post object — WordPress can hand one to its the_posts filters when a post is permanently deleted during a page load — caused a fatal error in the front-end translated-slug preload and in the admin post-list translations column. Both now skip such entries and behave exactly as before for every real post.
- Background jobs: deleting a job now also removes the export file it produced, instead of leaving it behind. An export whose post-write hook throws is no longer orphaned — the export succeeded, so the job keeps its result and reports the hook failure separately.
- Background jobs: when a scheduler refuses an event — a filter veto, a duplicate, or a queue error — that refusal is now reported and logged rather than silently producing no work. On multisite, a job lookup can no longer return another site's job when a single process handles more than one site.
- Performance: data import no longer flushes the entire object cache every 500 rows, which on sites running Redis or Memcached evicted every other plugin's cached data for the duration of the import.
- New filter: perflocale/url/query_var renames the query variable used by query-parameter URL mode, for sites where another plugin already owns 'lang'. Register it from an mu-plugin and flush permalinks afterwards.
- New filter: perflocale/jobs/deduplicate_admission restores the previous behaviour when a second identical background job is dispatched while the first is still in flight.
- New filter: perflocale/breaker/probe_lease_seconds sets how long a single worker holds the half-open probe lease on a tripped circuit breaker.
- Behaviour change: a save that changes none of the synced fields no longer writes to the translations, so save_post, post_updated and transition_post_status no longer fire for them on such a save and their modified dates stay put; exports now fail rather than silently omit a table that cannot be read; per-status translation counts may read higher on sites whose stored status had drifted; and perflocale/mt/pre_translate and perflocale/machine_translation/after now carry the source object's own language rather than the site default.
- Background jobs: when a job's export file cannot be removed, the job is still deleted and the leftover is logged rather than disappearing silently.
- Background jobs: recurring maintenance events now report whether the scheduler accepted them, and a refusal is logged with its reason instead of being indistinguishable from success. Site Health lists all four recurring events it checks, including the machine-translation usage cleanup.
- Background jobs: a long-running command or worker no longer reuses a stale answer about whether one of the plugin's jobs is in flight. On multisite that stale answer could come from a different site and delay Action Scheduler's recovery of other plugins' stuck tasks.
- Routing: query-parameter URL mode now honours the URL Prefix Format setting, so a site set to locale form serves ?lang=en-us rather than ?lang=en everywhere the plugin writes a URL, including the search form's hidden field and the WooCommerce AJAX endpoint. Requests using the other form are permanently redirected to the canonical one, so a language keeps exactly one indexable URL.
- Routing: a language could be made unreachable by its own slug. Locales are also matched in their URL form, and a single-pass lookup let a later language's locale form overwrite an earlier language's real slug — give one language the slug de-de and another the locale de_DE, and /de-de/ served the second one. Real slugs now always win.
- REST API: creating a language with a slug or locale that already exists returned HTTP 500 with a raw database error. It now returns 409 with slug_exists or locale_exists.
- Background jobs: the “is this event already scheduled?” check was a no-op under Action Scheduler, because an async action has no next-run timestamp for it to find, so scheduling guards that relied on it could queue duplicate work. It now asks Action Scheduler directly.
- Site Health: a new check tests whether your server blocks web access to the export directory and, if it does not, shows the rule to add for nginx and Caddy.
- Reliability: when the scheduler refuses a webhook retry, that refusal is recorded as a delivery failure instead of being discarded. Previously the retry vanished with no second attempt and no entry in the failure log. Failure-log lock contention is now reported rather than silently skipped.
- WooCommerce: block Cart and block Checkout work again on subdirectory multisite children. A shopper browsing in a non-default language made every Store API request return a 404 there, which broke the cart. Store API messages on those sites now come back in the site's default language; single sites, subdomain and per-domain networks are unaffected.
- REST API: a status WordPress refuses for a post type now returns 400 translation_status_rejected instead of 500. Nothing is written either way; the previous response told clients the server had broken when the request was at fault.
- Caching: new perflocale/cache/purge_urls action fires when a post's public visibility changes, carrying every affected front-end URL including each translation's. A translated page is a separate URL that a full-page cache usually does not know about, so the action lets a cache purge those pages too.
- Backup and restore: a replace-mode import is now all-or-nothing. If the database refuses even one row the whole import rolls back and your existing data is untouched, instead of committing everything that did land and reporting success. Settings, add-on settings, the disabled-add-on list and role grants carried in the bundle roll back with it. A PO import run with replace behaves the same way.
- Backup and restore: every write to an export file is checked. A disk that fills up, a quota that runs out, or a network share that drops a write partway through no longer produces a file that ends correctly and is corrupt in the middle — the export is abandoned and your previous backup is left in place.
- Background jobs: a worker that cannot record itself as running no longer runs the job anyway. A failed status write used to leave the job listed as queued while its work went ahead — translations created, provider credit spent — and the queued row stayed eligible to be run a second time.
- Background jobs: dispatching the same job twice — a double-clicked button, a retried request, the same --async command run again — now returns the job already in flight instead of queueing a second one. Different arguments, a different job type and the chunked site-translation chain all keep running in parallel.
- Machine translation: the circuit breaker now counts a provider that answers with something unreadable. A proxy returning HTTP 200 with an error page used to clear the failure count on every call, so the breaker could never trip. When the breaker opens and its cooldown expires, exactly one request is now let through to test the provider rather than every request that arrives at once.
- WP-CLI: import, po-import and network-import exit non-zero when the import reports errors. A malformed file previously printed a warning and then Success: with exit code 0, so a restore script could record a restore that never happened.
- REST API: malformed XLIFF — an empty body, XML that does not parse, a document declaring entities, or an unknown target language — now returns 400 instead of 500. An XLIFF file that repeats a unit identifier is applied once instead of once per repeat.
- Routing: translated permalinks on non-Latin sites could be generated broken. WordPress percent-encodes non-ASCII slugs, so an ordinary 26-character Japanese title becomes a 198-character slug; when two collided, the trim that made room for a -2 suffix cut through a percent-escape. Apache and nginx reject a malformed escape in a path with a 400 before WordPress runs, so that permalink could not be opened at all. The trim is now character-aware, exactly as WordPress does it for post slugs.
- Multisite: the notice explaining that PerfLocale cannot run alongside WPML, Polylang or TranslatePress now also appears in Network Admin, where WordPress does not fire the hook it previously used — so an operator who network-activated the other plugin saw nothing at all. It now also says their translations stay in the database and can be imported after deactivating.
- Add-ons: a failed add-on boot no longer leaves a permanent warning. WordPress replaces plugin files in place during an update, so a request landing mid-write can report a parse error for a problem that fixes itself on the next request — and that was recorded permanently, shown to every administrator. The record now retires as soon as the add-on boots successfully, and clearing a quarantine clears its boot errors too.