Developer reference for all apply_filters and do_action hooks in PerfLocale: 309 hooks, 236 filters and 73 actions. Each card shows the hook’s parameters, an example and its source file.
Filter which non-public post types are offered in the Advanced content types group at Settings → Translation.
Defaults to [ 'wp_block', 'wp_navigation', 'wp_template', 'wp_template_part' ] - synced patterns, block-theme navigation menus, and block templates and template parts.
This is a curated allowlist, not get_post_types( [ 'public' => false ] ): a checkbox is only offered for a type PerfLocale can actually serve a translation for. wp_template and wp_template_part are in the default list but are not shown as individual checkboxes - they are switched on together by the separate Full Site Editing setting, so removing them here puts them beyond that setting's reach too. See Full Site Editing.
Note: this list controls the settings UI only. It is separate from perflocale/translatable_post_types, which also governs language filtering of ordinary content queries.
Filter the human-readable label shown for a string’s context on the Strings screen.
A context is an identifier meant for code (blogname, attribute_label_size), but it is shown to an operator, so a domain that knows a friendlier name can supply one.
PerfLocale uses this itself to show Site Title and Tagline instead of blogname and blogdescription. Always scope your callback to your own domain, or you will relabel another plugin’s identically-named context.
Filter the admin URL PerfLocale links to when it offers to edit an object.
Use it when a post type’s editor is not WordPress’s own - a form builder, a page builder - or return an empty string to render no link at all.
Why this exists:get_edit_post_link() builds from the post type’s _edit_link, which WordPress only populates for types registered with show_ui. Contact Form 7 forms, WPForms forms and WooCommerce variations are all registered without it, so core returns null for exactly the objects a translation screen most needs to link.
Contract - read this before shipping a callback:
Return a string. An absolute http(s) URL with a host, or ''. A Stringable object is accepted and converted. Anything else - array, object, null, false - is discarded and treated as ''.
Relative URLs are rejected. A relative href resolves against whatever admin page the user is on, so it looks like a working link and goes nowhere. '' is always the safer answer.
IDs are current-blog. A bare integer has no network-wide identity; on multisite, switch_to_blog() before resolving.
Pass through what you do not own. Check $post_type first and return $url unchanged otherwise. Later callbacks see your return value, and returning a non-string can raise a TypeError inside another integration’s typed callback.
Exceptions are not contained. A callback that throws, or a Stringable whose conversion throws, propagates as WordPress normally would. Only the final value is validated.
This resolves a URL. It does not authorize anything. PerfLocale still applies its own capability checks, and the value is escaped at every HTML sink. A non-empty URL is not permission.
Parameters
Parameter
Description
string$url
URL resolved so far, '' when core could not.
int$post_id
Object being linked.
string$post_type
Its post type.
Returns
string
Example7 lines
add_filter( 'perflocale/admin/edit_post_link', function ( string $url, int $post_id, string $post_type ): string {
if ( 'my_form' !== $post_type ) {
return $url; // Always pass through what you do not own.
}
return admin_url( 'admin.php?page=my-builder&form=' . $post_id );
}, 10, 3 );
Filter the front-end URL PerfLocale offers as “View”.
Use it for a post type that is not publicly_queryable but is still reachable.
Why this exists:get_permalink() returns a URL for a non-viewable post type too - one that 404s - so an emptiness check cannot tell a live link from a dead one. PerfLocale gates on is_post_type_viewable() instead. That is correct for form post types and wrong for a WooCommerce variation, whose permalink resolves to its parent product and returns HTTP 200; the bundled WooCommerce integration restores it through this filter.
The same return contract applies as above: an absolute http(s) URL with a host, or ''.
Parameters
Parameter
Description
string$url
URL resolved so far, '' when the type is not viewable.
Whether Site Health actively tests that the export directory is not web-readable.
The check writes a short-lived random file into wp-content/uploads/perflocale/exports/, requests it over HTTP, and deletes it either way; the verdict is cached for an hour, and an inconclusive result for fifteen minutes.
Return false on a host where loopback requests are blocked and transients do not persist - the combination where the probe would repeat its timeout on every Site Health load without ever learning anything. Disabling it skips only the measurement: the check still appears and still tells you to add the server rule, because a filter should never be able to make a real exposure look green. If you turn this off, verify the rule yourself - see Security.
Parameters
Parameter
Description
bool$probe
Whether to write the canary and make the request (default true).
Filter the date format PerfLocale resolves for a language: from perflocale()->date_format(), and on the front end when it overrides WordPress's date_format option (that override applies only when the language has its own date format set).
Default is the language row’s date_format field if set, else the WP date_format option. Lets a theme/addon override per-language without saving to the language row.
Parameters
Parameter
Description
string$format
-
object|null$lang
-
Returns
string
Example6 lines
add_filter( 'perflocale/date_format', function ( string $format, ?object $lang ): string {
if ( $lang && $lang->slug === 'ja' ) {
return 'Y年n月j日'; // Japanese long date.
}
return $format;
}, 10, 2 );
Same shape as perflocale/date_format, for the time format: from perflocale()->time_format(), and on the front end when it overrides WordPress's time_format option (that override applies only when the language has its own time format set).
The whole per-language fallback map, before it is validated.
The $context argument says what is being asked - so the same site can fall back differently for, say, front-end rendering than for hreflang output. Whatever the filter returns is put back through the same guardrails as the stored setting (unknown slugs dropped, self-references removed, length capped), so a malformed return cannot corrupt resolution.
Parameters
Parameter
Description
array<string, array>$map
Language slug => ordered fallback slugs.
string$context
What the chain is being resolved for (empty string when unspecified).
Returns
array
Example4 lines
add_filter( 'perflocale/language/fallbacks', function ( array $map, string $context ): array {
$map['de-at'] = [ 'de' ]; // Austrian German falls back to German.
return $map;
}, 10, 2 );
The resolved fallback chain for ONE language, after the map has been read.
Use this when the decision depends on the language being resolved rather than on the whole map. As with the map filter, the returned chain is re-validated, so guardrails still apply.
Parameters
Parameter
Description
array<int, string>$chain
Ordered fallback language slugs.
string$slug
Language the chain belongs to.
string$context
What the chain is being resolved for.
Returns
array
Example6 lines
add_filter( 'perflocale/language/fallback_chain', function ( array $chain, string $slug, string $context ): array {
if ( 'hreflang' === $context ) {
return []; // Never advertise a fallback as an alternate.
}
return $chain;
}, 10, 3 );
Filter the new post data before a translation is created via wp_insert_post().
Parameters
Parameter
Description
array$data
Post data array (post_type, post_status, post_title, etc.).
int$source_id
Source post ID.
string$target_slug
Target language slug.
Returns
array
Example4 lines
add_filter( 'perflocale/translation/post_data', function ( array $data, int $source_id, string $target_slug ): array {
$data['post_author'] = get_current_user_id(); // Set current user as author.
return $data;
}, 10, 3 );
Filter the meta keys excluded from being copied when creating a translation.
The bundled ACF, Meta Box and Pods add-ons use this filter to keep the values of the source’s password fields out of a new translation: ACF password fields at any depth (inside groups, repeaters, flexible content and clones), Meta Box password fields stored as post meta of their own, and Pods password fields. A Meta Box group is one meta row holding all its sub-fields, so the Meta Box add-on also listens to perflocale/translation/created and rewrites each copied group row without its password sub-fields, in nested groups and every clone too; the other values of the row stay as copied. When content sync later seeds a group into a translation that has no row for it, the add-on takes the same sub-fields out through perflocale/sync/seed_meta_value.
Parameters
Parameter
Description
array$keys
Meta key names to exclude.
int$source_id
Source post ID.
Returns
array
Example4 lines
add_filter( 'perflocale/translation/excluded_meta_keys', function ( array $keys, int $source_id ): array {
$keys[] = '_my_private_meta'; // Don't copy this to translations.
return $keys;
}, 10, 2 );
Lock TTL (seconds) for the create_translation() critical section that serialises concurrent translation creation for the SAME (source_post, target_language) pair.
The default of 60s covers most real-world inserts including a meta + featured-image copy step that runs synchronously. Sites with slow save_post integrations (heavy SEO plugins, image regeneration on insert) can extend this so a legitimately-long insert doesn't lose its lock mid-flight.
Parameters
Parameter
Description
int$ttl
Lock TTL in seconds. Default 60. Floor 5.
Returns
int
Example2 lines
// Bump to 5 minutes if your save_post chain is heavy (image regen, SEO, ACF Field Sync).
add_filter( 'perflocale/translation/create_lock_ttl', static fn(): int => 300 );
Filter the fields synchronized across translations when a post is saved.
Built-in field keys:featured_image, menu_order, post_parent, post_date, post_author, comment_status, ping_status. Each of these is compared against the translation's current value first, and the translation is written only if something actually differs - so a save that changes nothing synced fires no save_post for the translations and leaves their modified dates alone. When a translation is written, only the synced columns and its modified date change: its content, title, excerpt, status and publish date (unless post_date is synced) stay as stored, whoever saves, and its save hooks run as for any save. When a synced post_parent moves a translation to another parent, WordPress makes its slug unique among the new parent’s children (for example by adding -2); any other write keeps the stored slug. The perflocale/cache/flush_object purge signal still fires for each translation. Any other value is treated as a meta key: rows are read with get_post_meta() and written row by row with delete_post_meta() + add_post_meta(), so multi-value meta survives.
Parameters
Parameter
Description
array$fields
Field names (built-in keys like featured_image, menu_order, or custom meta keys).
Runs immediately after perflocale/sync_fields and expands per-post dynamic meta keys that the post-type-scoped list can’t enumerate - e.g. ACF repeater / flexible-content rows, whose key set depends on each post’s actual row count (the static field list only knows row 0).
The callback receives the SOURCE post id so it can read the real row structure and append the remaining rows’ keys. PerfLocale’s bundled ACF addon uses this hook.
Parameters
Parameter
Description
array$sync_fields
meta keys to sync;
int$post_id
source post id.
Returns
array
Example8 lines
add_filter( 'perflocale/sync_fields/for_post', function ( array $fields, int $post_id ): array {
// Row 0 is already registered by the static field list, so add rows 1+.
$rows = (int) get_post_meta( $post_id, 'my_repeater', true );
for ( $i = 1; $i < $rows; $i++ ) {
$fields[] = "my_repeater_{$i}_subfield";
}
return $fields;
}, 10, 2 );
Filter the user whose rights gate the Sync Fields of a scheduled post that WordPress publishes without a person.
That is a publish in a cron run, or outside WP-CLI in a request of nobody logged in or of a user who may not edit the post. The publish then writes Sync Fields, and a changed post password, only to the translations this user may edit, as a save by that user would; 0 writes them to every translation. The default is the post author when the author may edit the post, otherwise 0. See Scheduled posts in the Sync Fields docs.
Runs once per such publish that syncs Sync Fields or copies a changed password: not for a post without translations or opted out of sync, and for Sync Fields not for a translation that Sync Fields marked Needs Update. A return that is not an integer of 0 or more leaves the default.
Parameters
Parameter
Description
int$user_id
User whose rights gate the publish, 0 for none. Default: the post author when the author may edit the post, otherwise 0.
int$post_id
Post being published.
Returns
int
Example2 lines
// A scheduled publish updates every translation, whoever wrote the post.
add_filter( 'perflocale/sync/scheduled_publish_user', '__return_zero' );
Veto the term-hierarchy sync for one translation sibling.
When a translatable term is edited, PerfLocale mirrors its parent onto every linked sibling - mapped into the sibling’s own language, so a German child points at the German parent rather than at the English parent’s term_id (which would orphan it cross-language). Name, slug, and description stay translator-owned and are never synced.
Return false to leave one sibling’s hierarchy alone while the rest keep mirroring - useful for a market that runs a deliberately flatter catalogue. The filter is only reached when the Sync Term Hierarchy setting (sync_term_hierarchy, default on) is enabled: that setting is the all-or-nothing switch, this filter is the selective one. It is also skipped when the edited term’s parent has no translation in the sibling’s language - PerfLocale leaves the sibling untouched rather than guess a cross-language parent.
Parameters
Parameter
Description
bool$sync
true by default (mirror the hierarchy).
WP_Term$source_term
the term that was edited.
int$sibling_id
the translation sibling term about to be updated.
string$taxonomy
taxonomy slug.
int$translated_parent
the source term’s parent mapped into the sibling’s language (0 = top level).
Returns
bool.
Example9 lines
// Let editors pin individual categories out of the hierarchy mirror.
add_filter( 'perflocale/sync/term_parent', function ( bool $sync, WP_Term $source_term, int $sibling_id, string $taxonomy, int $translated_parent ): bool {
if ( $taxonomy !== 'product_cat' ) {
return $sync;
}
// A sibling an editor flagged keeps whatever parent it has now.
return get_term_meta( $sibling_id, '_manual_hierarchy', true ) === 'yes' ? false : $sync;
}, 10, 5 );
Whether the per-language Featured Image metabox is registered on the post-edit screen for a given post type.
Default true. Returning false suppresses the metabox without disabling the frontend featured-image swap (existing per-language overrides keep working) - useful for themes or addons that render their own featured-image surface and want to host the language overrides inside that UI instead. Only consulted for post types that support thumbnails and when the active theme declares post-thumbnails support.
Parameters
Parameter
Description
bool$show
true by default.
string$post_type
the post type being evaluated.
Returns
bool.
Example4 lines
// Hide PerfLocale's metabox for products - a custom UI hosts the overrides.
add_filter( 'perflocale/media/show_featured_image_panel', static function ( bool $show, string $post_type ): bool {
return $post_type === 'product' ? false : $show;
}, 10, 2 );
Which languages the per-language Featured Image panel renders for the post being edited.
Defaults to PerfLocale’s already-filtered active-language list. Lets a theme or addon drop languages (for example hide RTL languages when the theme ships no RTL stylesheet) or, by returning the full unfiltered list, show every active language regardless of the default filtering.
Parameters
Parameter
Description
array<int, object>$languages
the languages PerfLocale would render.
int$post_id
the post being edited.
array<int, object>$all_languages
the unfiltered list of every active language.
Returns
array of language objects.
Example4 lines
// Only offer the site's two primary languages in the Featured Image panel.
add_filter( 'perflocale/media/featured_image_panel_languages', function ( array $languages, int $post_id, array $all_languages ): array {
return array_filter( $languages, static fn( $lang ) => in_array( $lang->slug, [ 'en', 'de' ], true ) );
}, 10, 3 );
Override the regex used to identify search-engine bots / crawlers.
Bots that match are exempted from the three automatic redirect paths - browser-language, edge-hint, and geo-IP - so they always see the URL they crawled. (Cookie-based detection is unaffected: those redirects already stand down for any visitor who has a perflocale_lang cookie.) The default pattern matches these case-insensitive, word-bounded tokens: bot, crawl, spider, slurp, mediapartners, googlebot, bingbot, yandex, baidu, twitterbot, applebot, facebookexternalhit, linkedinbot, duckduckbot, gptbot, slackbot, discordbot, telegrambot, petalbot, ahrefsbot, semrushbot, pinterestbot, oai-searchbot, chatgpt-user, claudebot, claude-web, claude-user, claude-searchbot, anthropic-ai, perplexitybot, perplexity-user, ccbot, bytespider, amazonbot, meta-externalagent, meta-externalfetcher, duckassistbot, mistralai-user, cohere-ai, diffbot - classic search and social crawlers plus the AI crawlers and assistant fetchers (OpenAI, Anthropic, Perplexity, Common Crawl, ByteDance, Amazon, Meta, DuckDuckGo, Mistral, Cohere, Diffbot), which index for AI search and answer engines and mis-index a language redirect exactly like a classic engine. Compound names are enumerated explicitly because \bbot\b sees a letter-letter boundary inside “googlebot”. Return an empty string to disable bot exemption entirely.
Parameters
Parameter
Description
string$pattern
Compiled regex (with delimiters and flags).
Returns
string
Example6 lines
add_filter( 'perflocale/router/bot_ua_pattern', function ( string $pattern ): string {
// Add a custom monitoring crawler to the bypass list.
// substr( $pattern, 3, -4 ) strips the leading '/\b' and the trailing '\b/i',
// leaving just the '(token|token|...)' group.
return '/\b(MyMonitor|' . substr( $pattern, 3, -4 ) . ')\b/i';
} );
When “Hide URL prefix for default language” is unchecked, the plugin 301-redirects bare URLs (e.g. / or /contact/) to the prefixed form (/en/, /en/contact/) to avoid duplicate content.
Return false to disable that redirect and let both forms resolve.
When “Hide URL prefix for default language” IS enabled, / is canonical, but the still-prefixed default-language home (e.g. /en/) also resolves to the same homepage - a duplicate-content URL. The plugin 301-redirects that bare /en/ back to the canonical /. This redirect is home-only: inner prefixed URLs such as /en/contact/ are left untouched (their per-page canonical tag already de-duplicates them). Return false to disable it and let both / and /en/ resolve. GET/HEAD requests only.
Override the URL mode in code (e.g. per environment via wp-config).
One of subdirectory, subdomain, domain, or query - the last being the ?lang= query-parameter mode that works with every permalink structure (including Plain) on any server; the default language always keeps clean, param-free URLs. MUST return the same value for every call within a site - every generated link, rewrite rule, and cached URL assumes one mode per site. Vary it per environment or per blog, never per request. Unknown values fall back to the stored setting.
Parameters
Parameter
Description
string$mode
one of subdirectory, subdomain, domain, query.
Returns
string
Example2 lines
// Force query-parameter mode on a staging box that can't rewrite.
add_filter( 'perflocale/url_mode', fn() => 'query' );
The default is lang, which is a generic name another plugin, theme, or page builder may already own - leftover Polylang installs are the common case. Renaming it moves PerfLocale out of the way without touching rewrite rules by hand: the URL writer, the language detector, and the generated rewrite rules all read this one value, so they cannot disagree.
The returned value is passed through sanitize_key(), and an empty or non-string return falls back to lang, so the result is always a safe query-argument name. The filter fires once per request (once per blog on multisite) no matter how many URLs the page builds, so it is not on any hot path.
Register this from an mu-plugin. The name is resolved while PerfLocale itself loads, because query-mode language detection has to read the request before plugins_loaded fires. A filter added from an ordinary plugin or from a theme’s functions.php runs too late and is ignored - silently, since there is nothing to warn about at that point. Files in wp-content/mu-plugins/ load first, so that is the supported home for it.
Renaming this is a URL change, not a cosmetic one. Existing links carrying the old name stop resolving, and in subdirectory mode the name is baked into stored rewrite rules - flush permalinks (Settings › Permalinks) after adding or changing the filter. Return the same value for every call within a site.
Parameters
Parameter
Description
string$query_var
default lang.
Returns
string
Example3 lines
// wp-content/mu-plugins/my-lang-var.php
// Leave `lang` to another plugin.
add_filter( 'perflocale/url/query_var', fn() => 'pfl_lang' );
Filter the full hreflang tag array (including the x-default entry) right before it’s emitted in <head> and on the Link HTTP header.
Use it to add, remove, reorder, or mutate entries.
Dev-mode guard: if a filter callback returns a non-array, PerfLocale emits a _doing_it_wrong() notice and falls back to the unfiltered tag list - hreflang output is preserved.
Parameters
Parameter
Description
array$tags
array of ['hreflang' => string, 'href' => string], computed per-request and cached.
Cache lifetime (in seconds) for the archive / paged hreflang block.
Each ?paged=N archive request caches its own computed hreflang HTML; the default 12 * HOUR_IN_SECONDS balances rebuild cost against reflecting a translation change within half a day if a narrow per-post flush is missed. Singular posts are kept fresh by the per-post invalidator regardless of this value.
Parameters
Parameter
Description
int$ttl
cache lifetime in seconds (default 12 * HOUR_IN_SECONDS).
Returns
int
Example2 lines
// Shorten archive hreflang caching to one hour.
add_filter( 'perflocale/seo/hreflang_cache_ttl', fn() => HOUR_IN_SECONDS );
Cleaner than walking the whole tag array when you only want to redirect search engines to a language-picker landing page or a specific regional variant. Return an empty string to suppress the x-default entry for the current request.
Parameters
Parameter
Description
string$default_url
URL currently slated for x-default (PerfLocale’s computed value).
// Point x-default at a language-picker landing page.
add_filter( 'perflocale/seo/x_default_url', fn() => home_url( '/languages/' ) );
// Only override for the homepage; fall back to PerfLocale's default elsewhere.
add_filter( 'perflocale/seo/x_default_url', function ( string $url ): string {
return is_front_page() ? home_url( '/choose-region/' ) : $url;
} );
Override the Include Fallback Languages setting programmatically.
When the filter returns true, hreflang emits a tag for every active language - even those without an explicit translation of the current post - as long as the language URL would render 200 (i.e. missing_translation_action is show_default or the language has a non-empty fallback chain). Filter value wins over the admin toggle, so addon or theme code can force the inclusive mode without touching settings.
Parameters
Parameter
Description
bool$enabled
current setting value (defaults to false).
Returns
bool
Example10 lines
// Always advertise every active language, regardless of admin setting.
add_filter( 'perflocale/seo/hreflang_include_fallbacks', '__return_true' );
// Turn on only for singular posts with an SEO-friendly post status.
add_filter( 'perflocale/seo/hreflang_include_fallbacks', function ( bool $enabled ): bool {
if ( is_singular() && 'publish' === get_post_status() ) {
return true;
}
return $enabled;
} );
Decide whether PerfLocale injects xhtml:link hreflang alternates into the WordPress core sitemap (/wp-sitemap.xml) on this request.
The default decision is computed from the seo_sitemap_source setting:
core - always inject into WP core sitemap, even when an SEO plugin is also active.
plugin - never inject into WP core (let the SEO plugin's addon handle alternates exclusively in its own native sitemap).
auto (default) - skip core when an SEO plugin (Yoast / Rank Math / AIOSEO) is detected via setting or runtime constant/class. Otherwise inject into core.
This filter lets sites with unusual sitemap setups (e.g. a third-party sitemap plugin PerfLocale doesn't recognise, or a Yoast sitemap that has been disabled at the Yoast settings level) override the auto-detection.
Parameters
Parameter
Description
bool$decision
the pre-filter decision derived from the seo_sitemap_source setting.
Returns
bool
Example5 lines
// Force injection into WP core even though Yoast is active (sitemap disabled in Yoast).
add_filter( 'perflocale/sitemap/inject_into_core', '__return_true' );
// Skip core sitemap injection unconditionally (custom sitemap plugin handles alternates).
add_filter( 'perflocale/sitemap/inject_into_core', '__return_false' );
Cap on how many translation-linked objects the sitemap integration batch-primes per post type / taxonomy.
The default is page-proportional: wp_sitemaps_get_max_urls() × the number of active languages (counted as at least 2) - i.e. the most a single sitemap page can actually consume, rather than a flat multiple of the page size. Whatever the filter returns is floored at 2,000, so you can raise the cap but not lower it below that. Selections above the cap skip bulk priming and fall back to per-entry lookups - output is identical either way.
Filter the BCP-47 locale emitted in the Content-Language HTTP response header.
Default is the current language’s locale field normalised to hyphen-form (e.g. de_DE → de-DE); return an empty string to suppress the header on this request.
Parameters
Parameter
Description
string$locale
-
object$current_lang
the language object from the router.
Returns
string
Example7 lines
add_filter( 'perflocale/content_language/value', function ( string $locale, $current_lang ): string {
// Force the generic region-less code on specific templates.
if ( is_page( 'global-press' ) ) {
return 'en';
}
return $locale;
}, 10, 2 );
Filter the CSS block emitted for the cross-document View Transitions opt-in.
The default block opts in to automatic navigation transitions with a 240ms crossfade AND includes a @media (prefers-reduced-motion: reduce) guard that zeros the animation duration for users with the OS-level reduced-motion preference (navigation still completes instantly - only the crossfade is suppressed). Returning '' disables output entirely. If you override the default, keep the reduced-motion guard in your replacement CSS to preserve WCAG 2.1 SC 2.3.3 compliance.
Parameters
Parameter
Description
string$css
default CSS block (includes @view-transition, the 240ms crossfade rules, and the prefers-reduced-motion guard).
Returns
string
Example5 lines
// Add named transitions so the header and main content morph across pages.
add_filter( 'perflocale/view_transitions/css', function ( string $css ): string {
return $css . 'header.site-header { view-transition-name: site-header; }
main { view-transition-name: main-content; }';
} );
Short-circuit the Speculation Rules emitter on specific templates (applies to BOTH the WP 6.8+ Core API path and the WP < 6.8 fallback).
Useful for pages where prerendering the switcher target would waste bandwidth - paginated archives with heavy media, or URLs that trigger non-idempotent GET side effects.
On WP 6.8+ PerfLocale registers its prerender rule via Core’s native Speculation Rules API (wp_load_speculation_rules action), so our rule lands inside Core’s single output script.
Return false from this filter to force the fallback self-emit path instead - a standalone <script type="speculationrules" id="perflocale-speculationrules"> emitted at wp_footer priority 20. Primarily useful for integrators who want the perflocale/prerender/rules filter (fallback-path only) to remain active on modern WP, or for test environments exercising the fallback code without downgrading WordPress.
The same path can be forced globally by defining the PERFLOCALE_FORCE_SR_FALLBACK constant - the filter is a per-request escape hatch, the constant is a site-wide switch.
Parameters
Parameter
Description
bool$use_core
defaults to true when \WP_Speculation_Rules exists.
Filter whether to wrap singular-post content with <div data-nosnippet> when the visitor is viewing a non-default-language URL that’s showing the default-language post as fallback (missing_translation_action = show_default).
Fires only when fallback is already detected; use this to exempt specific post types or IDs. The guard applies to both explicitly-linked default-language posts AND posts that have no translation-link entry (treated as default-language, matching the plugin-wide convention), so pages authored before PerfLocale was installed are protected too.
Parameters
Parameter
Description
bool$wrap
(default true)
int$post_id
-
Returns
bool
Example7 lines
add_filter( 'perflocale/fallback/wrap_nosnippet', function ( bool $wrap, int $post_id ): bool {
$post = get_post( $post_id );
if ( $post && $post->post_type === 'product' ) {
return false; // Let product descriptions through - SKUs are language-agnostic.
}
return $wrap;
}, 10, 2 );
Customise the HTML tag used to wrap fallback content.
Default is div; use span for themes that render the content in an inline context where a block-level wrapper breaks layout. Must match /^[a-z]{1,8}$/ - invalid values fall back to div.
The WordPress Core filter wp_speculation_rules_href_exclude_paths is honoured for PerfLocale’s prerender URLs on both code paths, because PerfLocale applies it itself. Core applies it only to its own main document rule - plugin-added list-source rules structurally cannot carry a where clause (WP_Speculation_Rules::add_rule() rejects it), so they never inherit Core’s exclusions. On WP 6.8+ add_core_rule() therefore runs the filter over the translation URLs before calling add_rule(), and on WP 6.4-6.7 the fallback path runs the same filter before emitting its own script. Exclusions like /cart/* or /checkout/* hold identically across WP versions.
Filter the HTML attributes for each per-language link in the switcher - inline, dropdown, list, and flags renderers alike.
Add analytics data-*, inject rel / title, override hreflang / lang / dir, or append additional CSS classes without forking the template.
Values are escaped via esc_attr() (or esc_url() for href). Boolean true emits an HTML boolean attribute; false / null omits the attribute entirely. Attribute names are sanitized to [A-Za-z0-9:_-].
Base attributes the switcher already sets on each option (the filter is your chance to add to or override them):
href - Target translation URL.
class - Per-renderer (perflocale-dd__option, perflocale-switcher-block__item, …).
hreflang - Target language slug.
lang - Target language slug. Lets screen readers pronounce "Français" / "العربية" with the right phonemes since the option label is in the target language, not the page language.
dir - rtl when the target language's text_direction is RTL; omitted otherwise. Lets RTL labels render right-to-left even on an LTR page.
role, tabindex, aria-selected - Set only on dropdown options for listbox semantics.
Dev-mode guard: a non-array return triggers _doing_it_wrong() and the renderer falls back to the built-in attribute map for that option.
Parameters
Parameter
Description
array$attrs
Attribute name => value pairs.
object$lang
Language object for this link (slug, name, native_name, locale, flag, text_direction).
Override the chevron icon rendered inside the dropdown trigger button.
Three built-in styles ship - single (one down chevron, the classic native-select look, default), double (stacked up + down chevrons), none (empty string). The filter fires AFTER the built-in lookup so it can substitute markup for any style key, including none - useful for themes that want to inject an icon even when the user picked "no icon" in Settings → Language Switcher.
Returned HTML is sanitized through kses_switcher() at every echo site - if your markup uses tags or attributes outside the switcher's built-in allowlist (e.g. <i> elements, data-* attributes, additional SVG tags), extend the allowlist via perflocale/switcher/kses_allowed_html below. Returning a non-string falls back to empty.
Parameters
Parameter
Description
string$html
Computed arrow HTML for $style (empty when $style is none or unknown).
string$style
Resolved style key (single, double, none, or any custom keyword a theme uses as a sentinel).
Returns
string
Example10 lines
// Use a Font Awesome icon instead of the inline SVG chevron.
add_filter( 'perflocale/switcher/arrow_html', function ( string $html, string $style ): string {
return '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
}, 10, 2 );
// Use a brand SVG, but only when the user picked the "double" style.
add_filter( 'perflocale/switcher/arrow_html', function ( string $html, string $style ): string {
if ( $style !== 'double' ) { return $html; }
return '<svg viewBox="0 0 20 20" width="1em" height="1em" aria-hidden="true"><use href="#brand-double-chevron"/></svg>';
}, 10, 2 );
Inject HTML at the START of the dropdown panel, inside the role="listbox" container, before the first option.
Useful for region groupings, search inputs, recently-used pickers, "Powered by …" notices, or any other panel chrome.
Items injected here are NOT options, so they should not carry role="option". Put non-interactive chrome inside a <div role="presentation"> so the parent listbox doesn't announce it as an option. Returned HTML is sanitized through kses_switcher() - extend the allowlist via perflocale/switcher/kses_allowed_html if your markup needs more.
Parameters
Parameter
Description
string$html
HTML to inject (default empty).
array$languages
Languages about to be rendered (objects with slug, name, native_name, locale, flag, text_direction).
Filter the inner HTML of each per-language switcher option - the content that sits between the wrapping <a> / <span> on every render surface (dropdown, inline, simple, list).
Wraps the flag + label by default; the filter lets integrators inject region badges, mixed-direction <bdi> wraps, custom icons, script-tag indicators, etc., without forking the renderer. The same hook fires from both the dropdown and the inline / simple / list paths, so a single callback covers every option surface.
The filter return is passed through kses_fragment() (the switcher's internal kses helper, same allowlist as kses_switcher()) before being concatenated into the output, so unsafe HTML in a filter callback is stripped on every render path - including the Gutenberg block render path, which WordPress doesn't otherwise sanitize. Tags / attributes outside the built-in switcher allowlist (e.g. <bdi>, <i> icon-font glyphs, data-*) must be permitted via perflocale/switcher/kses_allowed_html.
Parameters
Parameter
Description
string$inner
Default inner HTML (flag <span> + label <span>, either of which may be omitted depending on the showFlags / showNames / style settings).
object$lang
Language object for this option (slug, name, native_name, locale, flag, text_direction).
Extend the wp_kses allowlist used to sanitize switcher HTML before addons echo it (via LanguageSwitcherBlock::kses_switcher()).
Needed when a custom arrow_html, panel_before, panel_after, option_content, or link_attrs filter emits tags / attributes outside the switcher's built-in set (which covers nav, button, div, span, a, svg, path with their standard switcher attributes plus lang, dir, aria-*, tabindex, id).
The map follows the standard wp_kses_allowed_html shape: [ 'tag' => [ 'attr' => true, … ], … ].
Parameters
Parameter
Description
array$allowed
Current allowlist (already merged with the post-context tags + the switcher's structural tags / attributes).
string$html
The HTML being sanitized, for context-aware decisions.
Returns
array
Example11 lines
// Analytics addon: allow data-tracking-id on options.
add_filter( 'perflocale/switcher/kses_allowed_html', function ( array $allowed ): array {
$allowed['a']['data-tracking-id'] = true;
return $allowed;
} );
// Theme swaps the chevron for an <i> icon-font glyph.
add_filter( 'perflocale/switcher/kses_allowed_html', function ( array $allowed ): array {
$allowed['i'] = [ 'class' => true, 'aria-hidden' => true ];
return $allowed;
} );
Extend the wp_kses allowlist used to escape conditional-content block and shortcode output.
The default is core’s post allowlist extended with the elements and attributes WordPress core’s own block renderers emit - <iframe> (oEmbeds), inline <svg>, and srcset/sizes on responsive images - so ordinary block content survives escaping. If a page builder or custom block emits markup that still gets stripped, restore it here rather than disabling escaping.
Parameters
Parameter
Description
array$allowed
Allowed HTML map, standard wp_kses_allowed_html shape.
string$content
The rendered content being escaped, for context-aware decisions.
Filter directory patterns excluded from string scanning.
Defaults to vendor/, node_modules/, .git/, tests/, test/, cache/, build/ and dist/.
Patterns are matched relative to the directory being scanned, not against the file’s absolute path. So a vendor/ folder inside the tree you asked to scan is still skipped, while an installation that merely lives below a directory of that name - /var/www/test/, a checkout under build/ - is scanned normally.
Dev-mode guard: a non-array return triggers _doing_it_wrong() and the scanner falls back to the built-in excluded-paths list.
Short-circuit a machine-translation call with your own cached translation.
PerfLocale bundles no translation memory - this is the single hook point where an integration can answer the lookup itself. Return a non-empty string and it is used verbatim: the provider is never contacted, no provider quota is spent, and the REST response reports "source": "tm" instead of "mt". Two caveats: the string is not passed through wp_kses() the way a provider response is, so return markup you already trust; and the request still counts against PerfLocale’s own hourly budgets (perflocale/mt/rate_limit, perflocale/mt/rate_limit_site) because the rate-limit check runs before the lookup. Return null (the default) to fall through to the provider.
Scope: the block-editor translate endpoints only - perflocale/v1/block-translate on its single-text path (a batch request covering several blocks always goes to the provider, since a multi-leaf walk rarely has every leaf hit) and perflocale/v1/block-translate/from-source, the “fill from source” action. It does not run for whole-post translation, bulk machine-translation jobs, or string translation.
Parameters
Parameter
Description
string|null$translation
null by default (call the provider).
string$text
the source text.
string$source_lang
source language slug.
string$target_lang
target language slug.
Returns
a non-empty string to use as the translation; anything else (including '', false, or an array) falls through to the provider.
Decide per language whether the auto-translate flow may (re)translate a post.
When a default-language post is published, PerfLocale queues a background job that walks every active language and machine-translates the post into it. Before each language it computes a default decision: translate when there is no translation yet, or when the existing translation is an untouched stub (created with empty content). A sibling with non-empty content is assumed to be human-owned and is skipped - republishing the source must never drop raw MT over a translator’s refined text.
Return true to force a re-translation of an already-filled language, or false to skip one. The filter runs after the default language is excluded and after the Auto-Translate Target Languages scope (mt_auto_translate_languages) has been applied, so it only sees languages that were already in play.
Parameters
Parameter
Description
bool$should
the computed decision (true when the language has no translation or only an empty stub).
int$post_id
the source post being auto-translated.
string$lang_slug
the target language slug.
int$existing_id
the existing translation’s post ID, or 0 when none exists.
Returns
bool - false skips this language for this run.
Example13 lines
// A fast-moving feed post type is safe to re-machine-translate on every
// republish - except where an editor has taken ownership of the copy.
add_filter( 'perflocale/mt/should_auto_translate', function ( bool $should, int $post_id, string $lang_slug, int $existing_id ): bool {
if ( get_post_type( $post_id ) !== 'market_feed' ) {
return $should;
}
if ( $existing_id > 0 && get_post_meta( $existing_id, '_editor_owned', true ) === 'yes' ) {
return false;
}
return true;
}, 10, 4 );
Whether a machine-translation path sends a password-protected post’s title, content and excerpt to the provider.
Every path that sends a post asks this filter, only for a post that has a password; a post without a password is sent without asking. By default a path sends such a post only when someone picked that post; the automatic runs and the runs over every post do not. $send starts at the path’s default, named by $trigger:
auto_publish - Auto-Translate on Publish. Default false: the post is not translated.
auto_create - Auto-Translate on Create (the + Create action). Default false: + Create makes a copy of the original without contacting the provider.
site_translate - the site-wide job (the Translate the entire site panel and the REST site_wide mode). Default false: the post’s pairs count as skipped.
bulk - the bulk action on posts selected on the Translations screen, inline or as a background job. Default true.
cli_all - wp perflocale translate --all, in the WP-CLI process or, with --async, as a background job. Default false: the post counts as skipped.
rest - POST /machine-translate, POST /machine-translate/object and POST /translations/bulk-translate with post_ids. Default true.
ability - the perflocale/translate-post ability. Default true.
editor - the block editor’s Fill in from source and the block toolbar’s Translate action on the post being edited. Default true.
A path that is refused skips the post and reports it as it reports any skipped item: bulk runs, the site-wide job and wp perflocale translate --post-ids or --all count it as skipped; wp perflocale translate <id> prints a warning; the REST routes and the editor answer 403 password_protected, and the ability returns a password_protected error, with the message “This post is password-protected and was not sent for machine translation.”
Parameters
Parameter
Description
bool$send
whether to send the post: the default of $trigger above.
\WP_Post$post
the password-protected post whose text would be sent.
string$trigger
one of the triggers above.
Returns
bool.
Example7 lines
// Let every path, the automatic runs and --all included, send the password-protected pages of one type.
add_filter( 'perflocale/mt/send_password_protected', function ( bool $send, \WP_Post $post, string $trigger ): bool {
return $send || 'member_page' === $post->post_type;
}, 10, 3 );
// Never send a password-protected post, whichever path asks.
add_filter( 'perflocale/mt/send_password_protected', '__return_false' );
Filter the wp_remote_request() arguments right before an outbound call to a machine-translation provider.
Use it to raise the timeout for bulk jobs, inject proxy/tracing headers, set a custom user-agent, or route calls through a corporate gateway.
The default SSRF validation runs before this filter and cannot be bypassed: the URL is verified against the internal/private-IP blocklist first, then the filter gets to tweak args.
Filter the list of hosts that skip the address checks PerfLocale runs on a machine-translation provider URL.
It has two uses:
Reaching a provider on an internal network. A provider URL whose host is, or resolves to, a private, shared or reserved address is refused, and the refusal message names this filter. To use a self-hosted LibreTranslate on your LAN, or on a Tailscale or carrier-grade NAT address (the shared range 100.64.0.0/10), add the host name or IP address of its URL here.
Skipping the DNS step for a public provider. A custom provider host (for example a custom AI translator add-on calling api.cohere.ai) listed here skips the slow gethostbyname() lookup, which has no timeout.
Important: a listed host bypasses every check that follows the filter - treat the list as a security-relevant setting. A host you add here returns from validate_url() immediately, so the IPv4 private, shared and reserved range checks, the IPv6 unique-local (fc00::/7) and link-local (fe80::/10) checks, the DNS resolution, and the checks on the resolved address are all skipped for it, whether you add a host name or an IP address. Only the hard-coded localhost list (localhost, 127.0.0.1, 0.0.0.0, ::1, [::1]) is checked before the filter and therefore still applies. Add only hosts you control. Entries are normalised (non-strings dropped, then lowercased); the return value is cast with (array), so returning a non-array does not restore the built-in list - a string becomes a one-element trusted list, and null leaves no trusted hosts at all, sending every request (including the five bundled providers) through the DNS step.
Default trusted hosts (built into PerfLocale) are exactly four endpoints: translation.googleapis.com, api.deepl.com, api-free.deepl.com, api.cognitive.microsofttranslator.com. LibreTranslate, hosted or self-hosted, is not trusted by default. Anything else - a self-hosted LibreTranslate, a custom agency endpoint, an AI gateway - goes through the DNS step and the address checks unless you add it here.
Parameters
Parameter
Description
array<int, string>$hosts
Lowercase trusted hostnames. Add to or replace this array.
Returns
array
Example12 lines
// A self-hosted LibreTranslate on the office LAN and one on Tailscale.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
$hosts[] = 'libretranslate.office.lan';
$hosts[] = '100.101.102.103';
return $hosts;
} );
// A public custom provider host that skips the DNS step.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
$hosts[] = 'api.cohere.ai';
return $hosts;
} );
Upper cap (seconds) on how long a provider’s Retry-After header can make the worker sleep after a 429 rate-limit response.
Default 300 - matches what real providers advise during sustained overload, while stopping a malicious or buggy provider returning Retry-After: 86400 from blocking a PHP worker for ~24h per retry. On a 429 the worker first takes max( Retry-After, exponential-backoff ), then caps that at this value, so the effective sleep is min( max( retry_after, 2^attempt ), this_cap ). The cap itself is floored at 1s.
Parameters
Parameter
Description
int$max_seconds
default 300.
Returns
int
Example2 lines
// Never sleep more than a minute on a rate-limit backoff.
add_filter( 'perflocale/mt/retry_after_max_seconds', fn(): int => 60 );
Handle responses from the External Agency provider when the agency does not return a synchronous translation in the immediate POST response.
PerfLocale does not ship a built-in async callback receiver; this filter is the supported extension point for addons that want to add one. The default return is null, which makes the provider throw RuntimeException instead of saving the untranslated source text into the translation.
Return a non-empty string to use as the translation. Return null (or empty string) to fall through to the throw - appropriate when the addon hasn't received the agency callback yet.
When this fires: the agency returned a 2xx response but the decoded body has no translation string key. PerfLocale then throws, rather than returning the source text as a translation, unless this filter supplies a string. See Machine Translation → External Translation Agency.
Parameters
Parameter
Description
string|null$translation
Default null (causes the provider to throw).
array$response
Decoded agency response body.
string$request_id
UUIDv4 generated for this outbound request. Use it to correlate against a previously-received agency callback.
array$payload
Outbound payload sent to the agency: text, source_lang, target_lang, request_id.
// Sketch of an out-of-tree async receiver: an addon registers its own
// REST route (e.g. POST /mycorp/v1/agency-callback) with HMAC verification,
// stashes incoming translations into an option/transient keyed by
// request_id, and matches them back here when the next translate() runs.
add_filter( 'perflocale/mt/agency_async_response', function (
$translation,
array $response,
string $request_id,
array $payload,
array $context
) {
$cached = get_option( 'mycorp_agency_cb_' . $request_id );
if ( is_array( $cached ) && isset( $cached['translation'] ) ) {
delete_option( 'mycorp_agency_cb_' . $request_id );
return (string) $cached['translation'];
}
// No callback yet - let the core throw so the job fails loudly.
return null;
}, 10, 5 );
Register only leaf text values a human translator would rewrite - plain text, textarea, and rich-text fields. Never structural keys (repeater counts, layout arrays), URLs/emails, serialized arrays, or SEO focus keywords; those belong on perflocale/translatable_meta_keys (the copy/seed list), not here. SEO addons register titles/descriptions here when the "Translate SEO meta" setting is on; ACF/MetaBox/Pods register text fields when "Translate custom fields" is on.
After the filter, PerfLocale drops two kinds of key: any key matching perflocale/translation/dangerous_meta_patterns (never sent to a provider or overwritten), and any mirror key from perflocale/sync/mirror_meta_keys. The fourth argument names the translation the values will be written to, so a callback can compare the two posts. The bundled ACF addon uses it to leave out repeater and flexible-content rows whose structure on the translation no longer matches the source; those keys are counted as skipped.
Parameters
Parameter
Description
array$keys
Meta keys to machine-translate. Default [].
string$post_type
Post type being translated.
int$post_id
Source post ID (0 for type-level cost estimates).
int$translation_id
Translation post ID the values are written to (0 when unknown, as in the cost estimate).
Returns
array
Example6 lines
add_filter( 'perflocale/mt/translatable_meta_keys', function ( array $keys, string $post_type, int $post_id = 0, int $translation_id = 0 ): array {
if ( $post_type === 'product' ) {
$keys[] = '_my_product_tagline'; // A plain-text field.
}
return $keys;
}, 10, 4 );
Choose the machine-translation destination format for an individual meta key.
Return 'text' for plain-text meta (SEO title/description, plain custom fields) so the provider is called in text mode and the result is not entity-escaped; return 'html' (the default) for markup-bearing meta. Unknown keys stay 'html'. PerfLocale groups a post’s translatable meta by resolved format and makes one provider round-trip per format, so declaring a key as text keeps literal characters like & intact instead of turning them into &. The bundled SEO and ACF/MetaBox/Pods addons use this hook to route plain-text fields to text mode. The fourth argument is the source post, so the format can depend on the post’s own data; the ACF addon uses it to route each row of that post by the row’s own field type, so a field name that is plain text in one flexible-content layout and rich text in another is handled row by row.
Parameters
Parameter
Description
string$format
'html' (default) or 'text'.
string$key
Meta key.
string$post_type
Source post type.
int$source_id
Source post ID.
Returns
string
Example6 lines
add_filter( 'perflocale/mt/meta_key_format', function ( string $format, string $key, string $post_type, int $source_id = 0 ): string {
if ( $key === '_my_seo_title' ) {
return 'text'; // Plain text - don't entity-escape.
}
return $format;
}, 10, 4 );
Meta keys with MIRROR semantics during content sync: the sibling ends up holding exactly the source’s rows, and an absent or empty source value clears the sibling.
Rows identical to the source’s are left alone rather than rewritten. Which keys this set governs is narrower than it looks. Seed-only semantics - copied once into a sibling that has none, then owned by that sibling and never overwritten or deleted - are computed from the add-on-contributed keys (perflocale/translatable_meta_keys) plus anything perflocale/sync_fields/for_post appends, minus this set. Seeds are copied only from the group’s default-language post: a value first typed on a translation stays on that translation. A seed-only key whose name matches perflocale/translation/dangerous_meta_patterns is not synced at all, and a key listed in the translation’s _perflocale_seed_cleared meta (the Meta Box addon writes it when a field that held a value is saved empty there) stays empty. The configured Sync fields list and anything added through perflocale/sync_fields mirror unconditionally and cannot be made seed-only from here, so never put a value carrying translatable text into either of those. Mirror keys are also excluded from machine translation.
⚠️ Never put a value that contains translatable text on this list. The mirror is bidirectional: any group member’s save propagates to all the others, so a key holding words is destroyed in both directions - the translator saves and overwrites the original, then the next original save overwrites the translation. Page-builder layout documents hold their text inside them, so the bundled page-builder add-ons treat them (_elementor_data, _fl_builder_data, Oxygen’s _ct_builder_json and its older ct_builder_json spelling, _bricks_page_content, Oxygen 6’s data) as seed-only, and mirror only text-free keys such as Beaver’s _fl_builder_enabled.
Use mirror for values that are genuinely source-owned and carry no words: a flag, an ID, a numeric setting. If you want a structural change to propagate and translated text to survive, you need a format-aware merge that can tell a layout node from a localisable value - a whole-document mirror cannot give you both.
Parameters
Parameter
Description
array$keys
Meta keys to mirror (defaults to the configured sync fields).
string$post_type
Post type being synced.
Returns
array
Example10 lines
add_filter( 'perflocale/sync/mirror_meta_keys', function ( array $keys, string $post_type ): array {
// Source-owned, and contains no translatable text.
$keys[] = '_my_layout_enabled'; // A flag deciding whether the layout renders.
$keys[] = '_my_grid_column_count'; // A number.
// NOT the document that holds headings, paragraphs and button labels:
// mirroring that destroys a translator's work in both directions.
return $keys;
}, 10, 2 );
Filters one row of a seed-only meta key (see perflocale/sync/mirror_meta_keys) before content sync copies it into a sibling translation.
It runs only when a seed is written: the default-language post is saved, the sibling has no rows for the key and has not emptied it on purpose, and the source holds a value. Each row of a multi-value key is filtered on its own. The value arrives unslashed; return the row to write, and PerfLocale slashes it. The bundled Meta Box add-on uses this filter to take a group’s password sub-fields out of the seeded rows, as it does when a translation is created.
Parameters
Parameter
Description
mixed$meta_value
the row as read from the source, unslashed.
string$field
the meta key.
int$target_id
the sibling translation that receives the row.
int$source_id
the default-language post the row comes from.
Returns
mixed - the row to write. Default: the source’s row unchanged.
Example8 lines
add_filter( 'perflocale/sync/seed_meta_value', function ( mixed $value, string $key, int $target_id, int $source_id ): mixed {
// Keep a token stored inside a settings array out of the translations.
if ( '_my_box_settings' === $key && is_array( $value ) ) {
unset( $value['api_token'] );
}
return $value;
}, 10, 4 );
Hooks that wire PerfLocale into recent WordPress APIs: the Abilities API and its JS-side shim (WordPress 6.9+), and the AI Client, the Connectors API, and Block Hooks (WordPress 7.0+). Each surface is feature-detected - the Abilities filters (perflocale/abilities/js_screens, perflocale/abilities/js_payload) light up once wp_register_ability() exists (WordPress 6.9+); the AI Client surfaces require WordPress 7.0+. On a site without the relevant API these filters still fire but their underlying code paths stay inert.
Override the callable that WpAiClientProvider invokes.
Useful for tests, custom in-house AI gateways, or as a forward-compatibility shim if WordPress core renames the underlying function.
Parameters
Parameter
Description
null|callable$resolver
Callable accepting (string $prompt, array $args). Return null to fall through to auto-detection.
Returns
string
Example6 lines
add_filter( 'perflocale/mt/wp_ai_client_resolver', function () {
return function ( string $prompt, array $args ): string {
// Route to your own gateway and return raw model text.
return my_gateway_complete( $prompt, $args );
};
} );
Filter the argument array passed to the AI Client call.
The default is [ 'capability' => 'text-generation', 'temperature' => 0.2, 'timeout' => 10 or 60, 'model' => [ small models ] ]. Recognised keys:
model - a model ID ('gpt-5.4-mini') or an ordered list of preferences, each a model ID or a [ provider_id, model_id ] pair. It is passed to WordPress’s model preference: the first one a connected provider offers is used, otherwise WordPress chooses. The default is an ordered list of small models (OpenAI mini, Anthropic Haiku, Google Flash). null or [] leaves the choice to WordPress. Entries of any other shape are dropped.
provider - a provider ID, to use only that provider.
temperature - default 0.2; null sends none. While a temperature is sent, a model that does not advertise temperature support is not chosen, so an explicit model of that kind needs 'temperature' => null too. When a model refuses the temperature, the call is repeated once without it.
max_tokens, system_instruction.
timeout - seconds for the provider request: 10 for editor calls ($fast_fail true), 60 otherwise.
Parameters
Parameter
Description
array$args
-
bool$fast_fail
-
Returns
array
Example5 lines
// Prefer one model, then another from a named provider.
add_filter( 'perflocale/mt/wp_ai_client_args', function ( array $args, bool $fast_fail ): array {
$args['model'] = [ 'gpt-4.1-mini', [ 'anthropic', 'claude-haiku-4-5' ] ];
return $args;
}, 10, 2 );
Retarget the Block Hooks auto-insertion of the language-switcher block to a different anchor.
The default is core/site-title:after. Return a [ block_type, position ] pair, or false to keep the default.
Parameters
Parameter
Description
array|false$override
-
string$anchor_block_type
-
string$relative_position
-
Returns
array|false
Example2 lines
// Put the switcher at the end of the site navigation instead of after the title.
add_filter( 'perflocale/switcher/auto_insert_anchor', fn() => [ 'core/navigation', 'last_child' ] );
Override the per-context default attributes the switcher carries when auto-inserted by Block Hooks.
Defaults to dropdown display + compact 16-px flag for header context. Return an empty array to fall back to the global Settings → Language Switcher values.
Filter the payload localised into the JS Abilities shim.
Use this to remove specific abilities from JS-side registration (e.g. keep translate-post server-only) or to inject custom ability descriptors. The shape is [ 'abilities' => [ { name, label, ... }, ... ] ].
Supply the callable PerfLocale uses to look up the MT-provider keys (and any key added through perflocale/connectors/slug_map) after environment variables and constants and before the database.
It receives a slug (deepl, google-cloud-translation, microsoft-translator, libretranslate) and returns the key, or null or an empty string when it has none. Current WordPress releases do not hand these keys out, so the built-in lookup finds none of them: return a resolver when your site keeps them in its own store. Also useful for tests.
Parameters
Parameter
Description
null|callable$resolver
-
Returns
?string
Example6 lines
add_filter( 'perflocale/connectors/resolver', function () {
return function ( string $slug ): ?string {
// Pull from your own secrets store.
return MyVault::get_key( $slug );
};
} );
Extend the setting-key → connector-slug map to look up additional API keys through the resolver (the defaults cover the 4 MT providers only).
PerfLocale ships no GeoIP or exchange-rate provider of its own, so any key those surfaces need is one your site registered - add it here to have your resolver supply it too. Unknown connector slugs simply return null from the resolver and fall through to env / constant / DB as usual.
Parameters
Parameter
Description
array<string, string>$map
perflocale_setting_key => connector_slug.
Returns
array
Example6 lines
add_filter( 'perflocale/connectors/slug_map', function ( array $map ): array {
// Setting keys registered by your own geo / FX provider wiring.
$map['my_geo_provider_key'] = 'acme-geo';
$map['my_fx_api_key'] = 'acme-fx';
return $map;
} );
Master switch for the found-rows pagination optimization.
On language-scoped archive queries PerfLocale replaces WordPress core’s SQL_CALC_FOUND_ROWS full-scan with no_found_rows plus a generation-cached COUNT, reproducing WP_Query::set_found_posts() branch-for-branch so pagination totals stay correct. Return false to keep core’s SQL_CALC_FOUND_ROWS behaviour. It only evaluates for a non-singular main archive query that is not a search, has not already opted out of counting and carries PerfLocale’s language JOIN - all / empty fields, a non-zero perflocale_language_id, on a site that has translation groups.
Post types that are TRANSLATABLE but must never be language-scoped by the query filter.
Use it for content a plugin resolves by IDENTITY - a hash, a slug, a name, a meta value - rather than browsing by language. Scoping such a lookup does not filter it, it breaks it: the lookup returns nothing and the host has no “not found” story beyond an error string or a blank region. The default set is wp_template, wp_template_part, wp_global_styles and wp_navigation; the bundled Contact Form 7, Oxygen and WPForms addons add their own types. Do NOT add a type that is genuinely browsed by language (post, page, product) - that would put every language’s content into every archive, search result and listing.
Parameters
Parameter
Description
array<int, string>$types
Post type slugs.
Returns
array
Example4 lines
add_filter( 'perflocale/query/never_scoped_post_types', function ( array $types ): array {
$types[] = 'my_form'; // Resolved by hash, never browsed by language.
return $types;
} );
Opt in to re-checking that a translated template part still belongs to the active theme before substituting it.
Off by default because it costs an extra lookup on a hot rendering path; turn it on if you switch themes between requests (a staging/preview setup, or a theme switcher) and would rather pay the lookup than risk serving a part from the previous theme.
Whether saving a block template or template part fans out copies to every active language.
Return false to keep template translations entirely manual.
The filter is only reached after PerfLocale has already decided the save is a real one: revisions, autosaves and the auto-draft, trash and inherit statuses never get here, Full Site Editing must be on with the type translatable, and the saved post must itself be the default-language member of its group. Languages that already have a live translation are skipped, so a callback returning true only ever creates what is missing. Each copy is created as a draft seeded with the source’s blocks, so nothing reaches visitors until you publish it.
Parameters
Parameter
Description
bool$enabled
true by default.
WP_Post$post
The template being saved.
Returns
bool
Example4 lines
// Fan out full templates, but leave template parts alone.
add_filter( 'perflocale/fse/auto_create_translations', function ( bool $enabled, WP_Post $post ): bool {
return 'wp_template_part' !== $post->post_type;
}, 10, 2 );
Dev-mode guard: a return whose shape is not array<string, AddonInterface> triggers _doing_it_wrong() and the registry falls back to the unfiltered addon list.
Per-addon override of the global Delete data on uninstall setting.
Lets a single addon opt out of (or into) data deletion regardless of the plugin-wide preference. Receives the planned PurgePlan so callers can branch on which tables / option keys would be removed.
Parameters
Parameter
Description
bool$delete
-
string$addon_id
-
PerfLocale\Addon\PurgePlan$plan
-
Returns
bool
Example7 lines
add_filter( 'perflocale/addon/delete_data_on_uninstall', function ( bool $delete, string $addon_id, $plan ): bool {
// Always preserve the WooCommerce addon's data even if the user toggled global deletion on.
if ( $addon_id === 'woocommerce' ) {
return false;
}
return $delete;
}, 10, 3 );
Use it to run an integration in some contexts only, for example not in WP-CLI or not on one site of a network. It is asked once per addon per request, when the registry boots addons (init priority 0 on a normal request), after the quarantine check and before the addon’s own compatibility check.
At that point only request-level context is known: admin or front end, REST (Helper::is_rest_request()), WP-CLI, cron, AJAX and the current site. The language and the queried object are not known yet, so a callback cannot depend on them.
false skips the boot for this request only. The addon’s hooks are not added, perflocale()->is_addon_active() returns false, and the Addons page shows the card as Not running when the admin request itself turned it off. This is not a failure: the addon is not reported as incompatible or quarantined, its failure counter is not changed, and the disabled list behind the Addons page switch stays as it is. true boots an addon that the disabled list turns off. Quarantined addons are skipped before this filter runs.
Theme addons. The Blocksy, Kadence and Neve addons are decided earlier, while the PerfLocale plugin file loads, so that they can register before the theme sets up. For those three, only a callback added by a must-use plugin, or by a plugin that loads before PerfLocale, takes effect. A callback in a theme’s functions.php runs too late for them.
Parameters
Parameter
Description
bool$enabled
Default false when the addon is in the disabled list (the Addons page switch), otherwise true.
string$addon_id
Addon identifier.
Returns
bool
Example8 lines
// wp-content/mu-plugins/perflocale-addons.php
// Keep the WooCommerce integration out of WP-CLI runs.
add_filter( 'perflocale/addon/enabled', function ( bool $enabled, string $addon_id ): bool {
if ( 'woocommerce' === $addon_id && defined( 'WP_CLI' ) && WP_CLI ) {
return false;
}
return $enabled;
}, 10, 2 );
How many consecutive boot failures an addon is allowed before the registry auto-quarantines it (skipping its boot() on subsequent requests).
The default of 3 tolerates transient issues like a missing dependency during an upgrade window or a one-off OOM; raise it for flaky network-dependent addons, lower it to fail-fast in development.
The result is memoised per request so the filter only fires once even when many addons boot. Returning 0 (or a negative number) disables quarantine entirely; 1 quarantines on the first failure. Operators see quarantined addons in the Addons admin page with the last error message inline, and can reset via wp perflocale addon reset-quarantine <id>.
Parameters
Parameter
Description
int$threshold
Default 3. Min effective value 1; values ≤ 0 disable quarantine.
Returns
int
Example8 lines
// Raise tolerance for a known-flaky addon to 10 consecutive failures.
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 10 );
// Quarantine on the very first failure (strict).
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 1 );
// Disable quarantine entirely (every failure is retried on next request).
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 0 );
Suppress the developer-mode _doing_it_wrong nudge that fires when an addon's soft-prefix uninstall-manifest entries (transient prefixes, cron hooks, post/user/term/comment meta keys) don't include the per-addon sub-namespace (perflocale_{addon_id}_, plus _perflocale_{addon_id}_ for meta keys).
The notice is purely a DX hint - soft-prefix per-addon isolation is not enforced (see Hard vs soft prefixes), and the global perflocale_ prefix is the only namespace requirement. The notice is also WP_DEBUG-gated by core, so production users never see it. Filter it off for addons that intentionally coordinate across the addon namespace (e.g. a manager addon cleaning up after a family of related sibling addons).
Parameters
Parameter
Description
bool$check
Default true. Return false to skip the check for this addon.
string$addon_id
Identifier of the addon whose manifest is being written.
Returns
bool
Example7 lines
// Suppress for one specific coordinating addon.
add_filter( 'perflocale/addon/manifest/check_soft_prefix_namespacing', function ( bool $check, string $addon_id ): bool {
return 'mycorp_coordinator' === $addon_id ? false : $check;
}, 10, 2 );
// Suppress globally during a noisy migration.
add_filter( 'perflocale/addon/manifest/check_soft_prefix_namespacing', '__return_false' );
Declare the data an add-on, Pro or another extension stores, so export, import, translation copy and Sync Fields treat it as declared.
Each entry is keyed <kind>:<name> and has every key of a core entry: kind (option, network_option, transient, site_transient, post_meta, term_meta, user_meta, order_meta or cron), name, owner (addon:<id>, pro or ext:<slug>), class, export, not_exported, import, uninstall (data or keep; always keep for cron), scope, copy, sync, deactivate, fixture and since. Entries are only added: one that collides with or overlaps a PerfLocale entry, or fails validation, is ignored.
An exported entry (only into the Add-on data section) must declare sanitize_import, a callable fn( mixed $value, string $name ): mixed the importer passes each imported value through before storing it. An exporting entry without one is ignored, and so is one whose name is a WordPress core option or meta key, or looks like a credential (see perflocale/data_registry/is_secret).
By default every add-on's exported data is chosen with the one Add-on data checkbox. An exported entry may declare group ([ 'id' => 'my-addon', 'label' => __( 'My add-on', 'my-addon' ) ]) to get its own checkbox with that label; the id is 1 to 40 lowercase letters, digits, - or _.
sync => 'never' for a key outside the _perflocale_ namespace is enforced when Settings are saved; to keep it out of a sync started another way, also remove it in perflocale/sync_fields.
Whether an extension entry whose name looks like a credential really holds a secret. Default true: the entry is not exported.
An extension entry exported into Add-on data whose name looks like a credential (the names the export redaction strips: _api_key, _secret, _password, _token, a name ending in _key and similar) is refused, so its value never travels in an export file. Return false only for a name that holds no secret, such as a _token_count counter; the entry is then exported and imported like any other.
Parameters
Parameter
Description
bool$is_secret
Default true.
string$name
The entry's name (an option or meta key, or a name pattern).
PerfLocale ships the scheduled multi-currency sync mechanism but no rate provider and no endpoint - the built-in provider list is empty, so this filter (or exchange_rates_fetched) is how a site supplies rates. Until one of them is wired, the sync contacts nothing.
Filter rates after the fetch step but before saving.
The simplest way to supply rates without registering a full provider: return the map you want stored. Also useful for applying a markup or overriding a single currency.
Parameters
Parameter
Description
array$rates
Currency code => exchange rate.
string$base_currency
WooCommerce base currency code.
string$provider_id
Selected provider ID (empty when no provider is registered).
Master switch for cross-language product-data sync, re-evaluated per request.
Defaults to the Sync Inventory Across Language Variants setting (wc_sync_stock, default on). Returning false suppresses every sibling write - product fields, variation fields, order-driven stock changes, and CSV-import syncs alike - without touching the stored setting, which makes it the right hook for a temporary freeze during a bulk operation.
This is the site-wide switch; use skip_inventory_sync to exempt a single product instead.
Parameters
Parameter
Description
bool$enabled
The wc_sync_stock setting (default true).
Returns
bool
Example4 lines
// Suspend inventory mirroring while a staged catalogue rebuild runs.
add_filter( 'perflocale/woocommerce/inventory_sync_enabled', function ( bool $enabled ): bool {
return get_option( 'acme_catalog_rebuild_running' ) ? false : $enabled;
} );
The variation-level twin of synced_product_fields: controls which meta keys are copied from an edited variation onto the attribute-matched variation of every sibling-language parent.
Siblings are located by anchoring on the linked parent product and matching children on their exact normalised attribute set - a variation whose attributes a translator has diverged is skipped rather than guessed at.
Default list:_stock, _stock_status, _manage_stock, _backorders, _price, _regular_price, _sale_price, _sale_price_dates_from, _sale_price_dates_to, _sku, _global_unique_id, _weight, _length, _width, _height, _virtual, _downloadable. The five price keys are removed before the filter runs when Synchronize Prices Across Languages (wc_sync_prices) is off, so a per-language pricing setup already sees the shorter list. Returning an empty array skips the sibling sync for that variation entirely.
Parameters
Parameter
Description
array$fields
Variation meta keys to sync.
int$variation_id
The variation being saved.
Returns
array
Example7 lines
// Mirror a custom lead-time field across languages, but let each
// market keep its own SKUs.
add_filter( 'perflocale/woocommerce/synced_variation_fields', function ( array $fields, int $variation_id ): array {
$fields[] = '_acme_lead_time_days';
return array_values( array_diff( $fields, [ '_sku' ] ) );
}, 10, 2 );
Whether a row written by the WooCommerce CSV product importer triggers a sibling sync.
Default true. A row with no translations costs one cached group lookup and short-circuits, but importing a fully translated catalogue runs a sibling sync per row - return false to skip it for the duration of the import and do a single resync afterwards instead.
Hooked to woocommerce_product_import_inserted_product_object, so it covers both products and variations written by the importer. Ordinary admin, REST, and quick/bulk-edit saves are unaffected.
Parameters
Parameter
Description
bool$sync
true by default.
WC_Product$product
The imported product or variation object.
Returns
bool
Example7 lines
// Skip per-row sync during CSV imports; resync in one pass afterwards.
add_filter( 'perflocale/woocommerce/sync_on_import', '__return_false' );
// Or narrow it to variations only, which are the expensive rows.
add_filter( 'perflocale/woocommerce/sync_on_import', function ( bool $sync, WC_Product $product ): bool {
return $product instanceof WC_Product_Variation ? false : $sync;
}, 10, 2 );
Whether to skip inventory sync for this product. Not a flat false: the incoming value is the per-product opt-out meta (_perflocale_sync_optout = yes), which a variation also inherits from its parent product. Return $skip unchanged to preserve the user's opt-out.
Whether Store API product data outside a cart counts the pending stock holds of every language copy of a product.
With Inventory Sync on, the language copies of a product share one stock, so the cart, add-to-cart, checkout and pay-for-order checks always count the holds of every copy (see Shared stock before payment). The quantity limit shown in Store API product data - product blocks and the Store API products route - is WooCommerce’s own by default and costs no extra query. Return true to count every copy there too, so the limit a shopper sees matches the one the cart enforces. That adds database reads for each translated, stock-managed product on those renders when no persistent object cache is in use.
Asked each time the Store API computes a product’s quantity limit outside a cart.
Whether cart and checkout stock checks use stored stock maps.
With Inventory Sync on, the language copies of a product share one stock, so each stock check of a variation in a cart or an order first finds the matching variation of each language copy. PerfLocale stores that answer per variation for up to a day and checks it against the products on every read. Stock quantities, held stock, stock management and the per-product opt-out are always read live and never stored.
Return false to look the matching variations up on every check instead, for example to rule the stored maps out while you look into a stock question. Each cart, checkout and Store API cart request with a stock-managed variation then makes a few more database or object-cache reads. When you return true again, maps stored before you switched them off are used again unless a change made through WordPress, or Clear cache, retired them meanwhile.
After changing translation links of attribute terms directly in the database, outside WordPress, use Clear cache: PerfLocale’s other caches need it too, and it retires every stored map whether or not this filter is set.
Seconds PerfLocale waits after a change that retires the stored stock maps before it stores new ones.
Default 60. Floor-clamped to 60, so a filter cannot remove the wait. Raise it when database reads can lag the primary or an import keeps a transaction open longer. A very large value stops new maps from being stored; to switch them off, use perflocale/woocommerce/stock_map_enabled.
Parameters
Parameter
Description
int$seconds
Default 60.
Returns
int
Example4 lines
// Database replicas can lag by a few minutes on this host.
add_filter( 'perflocale/woocommerce/stock_map_settle_seconds', function ( int $seconds ): int {
return 300;
} );
Whether to send Vary: Cookie on WooCommerce Store API responses whose language may come from the perflocale_lang cookie, that is, requests to the unprefixed /wp-json/wc/store/ base.
Off by default. Turn it on only when your page or edge cache honours Vary; otherwise bypass that path in the cache, or add the perflocale_lang cookie to its cache key (see Store API responses and caches).
Asked when a Store API response is served on a request whose URL carries no language. Responses that already carry Cache-Control: no-store, such as the cart and checkout routes, are skipped. The header is added next to any existing Vary value, so WooCommerce’s Vary: Origin stays.
Parameters
Parameter
Description
bool$vary
Default false.
WP_REST_Request$request
Store API request being served.
Returns
bool
Example2 lines
// The page cache in front of this site honours Vary.
add_filter( 'perflocale/router/store_api_vary_cookie', '__return_true' );
Whether one WooCommerce order email renders in the order’s language rather than the shop’s.
Default is WooCommerce’s own answer, WC_Email::is_customer_email() (and true for an email object with no such method): an email addressed to the customer uses the customer’s language, one addressed to the store admin uses the shop language. Of the nine order emails PerfLocale hooks, new_order, cancelled_order and failed_order go to the store admin.
Returning false does two things: no locale is switched for the body, and the subject, heading and additional content are left as WooCommerce formatted them - the String Translation lookup is skipped too.
Parameters
Parameter
Description
bool$uses
default: whether WooCommerce considers this a customer email.
Mail sub-keys taken from the SOURCE form rather than from the translation.
By default a translated form owns its whole mail array, ROUTING INCLUDED - the recipient, sender and additional headers were copied when the translation was created and are never re-synced. That is deliberate, because a different recipient per language is a legitimate setup, and editing the source flips the translation to Needs update so the change is visible. Use this filter when you would rather have mail routing follow the source while the wording stays translated.
Filter the translations applied to a Gravity Forms form for the current language, evaluated at render time.
Gravity Forms keeps forms in its own tables - a GF form id is not a wp_posts id - so per-form translations live in a per-form option (perflocale_gf_translations_{form_id}) keyed by language slug rather than in the post-based translation flow. Return an empty array to leave a form untranslated, or supply the translations array to override labels, descriptions, placeholders, choices, and confirmations programmatically without writing to the option store.
Parameters
Parameter
Description
array$translations
Stored translations for this form + language (empty when none). Shape: [ 'fields' => [ field_id => [ 'label', 'description', 'placeholder', 'choices' ] ], 'confirmations' => [ id => [ 'message', 'url' ] ] ].
Per-request override of the saved redirect priority order.
Receives the cleaned, sanitised order array - useful for forcing geo first for staff cookie holders, or falling back to browser while a third-party geo provider is rate-limited.
Parameters
Parameter
Description
array$order
Sanitised priority array. Known sources: geo, browser, edge_hint (any other slug is dropped by the sanitiser). Default order: geo, browser, edge_hint.
Returns
array
Example7 lines
add_filter( 'perflocale/redirect/priority_order', function ( array $order ): array {
if ( isset( $_COOKIE['acme_staff'] ) ) {
// Trust GeoIP first for the team, then fall back to browser language.
return [ 'geo', 'browser', 'edge_hint' ];
}
return $order;
} );
PerfLocale ships the redirect logic but no provider and no endpoint - the built-in provider list is empty, so the feature contacts nothing until a site wires a source through this filter or through perflocale/geo/lookup_country (the cheaper option when your host already hands you a country header).
The resolved map: keys are uppercase ISO 3166-1 alpha-2 country codes, values are language slugs (e.g. [ 'DE' => 'de', 'AT' => 'de' ]). This is not the raw geo_country_map setting - that stores language slug => comma-separated country codes and is already expanded, uppercased and validated before this filter runs. The default language is always excluded from the map.
Final short-circuit before the outbound IP-lookup HTTP request is made.
Return false to skip GeoIP redirection entirely for the current request - no provider call is issued. Complements the existing perflocale/privacy/consent_given gate.
Default 302 (temporary). Return 301 for permanent SEO consolidation. Allowed values: {301, 302, 307, 308}; anything else silently reverts to the default.
The filter also runs when a visitor opens a post under another language’s slug and the post has a published translation in the current language. There $from_slug and $to_slug are both the current language, and the default is 301: the visitor goes to the translation in one permanent redirect.
Parameters
Parameter
Description
int$status
default 302; 301 when redirecting to the current language’s own translation.
string$from_slug
current (missing) language slug.
string$to_slug
resolved fallback language slug.
int$from_post_id
source post being rendered.
int$to_post_id
target post ID (0 when redirecting to the language homepage).
Returns
int
Example4 lines
// Permanent redirects for en_US → en_GB specifically.
add_filter( 'perflocale/fallback/redirect_status', function ( int $status, string $from, string $to ): int {
return ( $from === 'en-us' && $to === 'en-gb' ) ? 301 : $status;
}, 10, 3 );
PerfLocale skips writing the perflocale_lang cookie on a visitor’s first request when the value would carry no information: WooCommerce alone makes the cookie useful on the site, the request resolves to the default language anyway, and no edge worker is steering.
Skipping it means those pages carry no Set-Cookie, so a full-page cache in front of the site can store them. Return false to write the cookie in that case too.
Nothing the plugin does depends on that cookie being present for a default-language visitor: the cart, checkout and order emails all fall back to the default language, which is the same answer the cookie would have given. Override this only if something of your own - an edge rule, an analytics script, a CDN cache key - reads perflocale_lang on default-language pages and needs it there.
Translated pages always write the cookie: the WooCommerce Store API posts to an address with no language in it and resolves the shopper’s language from this cookie, so a non-default shopper must carry one.
Parameters
Parameter
Description
bool$skip
Whether to skip the redundant write.
string$slug
The language slug that would have been written.
Returns
bool
Example2 lines
// Write the cookie on first default-language visits as well.
add_filter( 'perflocale/cookie/skip_redundant_write', '__return_false' );
Whether to write the perflocale_lang cookie on this request.
Use it for decisions the Cookieless mode setting cannot make, such as following one preference category of a consent plugin. It is asked only right before the cookie would be written, so a request that writes nothing never reaches it: a bot, perflocale/privacy/consent_given returning false, headers already sent, an unknown slug, a visitor who already holds this value, no feature that reads the cookie, or a skipped redundant write. It cannot force a write in any of those cases, and its answer does not change whether a visitor is redirected.
The filter wins over the Cookieless-mode setting in both directions: true writes the cookie even in Cookieless mode, and false blocks it while the setting allows it.
In Cookieless mode, hooking this filter also keeps the automatic first-visit redirects (browser language, GeoIP, edge hint) running. With Cookieless mode on and nothing hooked here, the cookie can never be written, so those redirects do not run: without the cookie nothing marks a visitor as returning, and every default-language page would redirect them again.
Parameters
Parameter
Description
bool$enabled
Default true; false when Cookieless mode is on.
string$slug
Language slug about to be written.
Returns
bool
Example5 lines
// Write the language cookie only after "preferences" consent.
// my_consent_for() stands for your consent plugin's own check.
add_filter( 'perflocale/language_cookie/enabled', function ( bool $enabled, string $slug ): bool {
return $enabled && my_consent_for( 'preferences' );
}, 10, 2 );
Gate for PerfLocale’s perflocale_lang cookie and its automatic GeoIP + browser-language redirects.
Return false to suppress the cookie and both redirects until the visitor has granted consent. Default true - the cookie and both redirects run immediately. (To drop the cookie unconditionally without a consent plugin, use the Cookieless-mode setting instead.)
Parameters
Parameter
Description
bool$granted
default true.
Returns
bool
Example10 lines
// Hook any consent-management plugin (Cookiebot, Complianz, Iubenda,
// OneTrust...) that exposes a "consent given for marketing/functional
// cookies" check.
add_filter( 'perflocale/privacy/consent_given', function (): bool {
if ( ! function_exists( 'complianz_has_consent' ) ) {
return true; // Consent plugin not active - behave as before.
}
return (bool) complianz_has_consent( 'functional' );
} );
Filter the JSON payload returned by GET /perflocale/v1/config before it is cached and served to edge runtimes (Cloudflare Worker, Vercel Edge, Netlify Edge, …).
Use it to carry feature flags, per-language A/B variants, fallback chains, or any routing metadata the edge needs without an extra origin round-trip.
The result is stored in the 3-layer cache and contributes to the ETag. Keep additions deterministic (no timestamps, no request-specific data). If the custom fields change at runtime, call \PerfLocale\Api\ConfigController::invalidate() or update any option/setting that already triggers perflocale/settings/updated.
Parameters
Parameter
Description
array$payload
The full config payload (version, url_mode, url_prefix_type, default_slug, hide_default_prefix, excluded_paths, detection_order, edge_hint_header, edge_hint_cookie, languages).
Whether the list of active languages may be read anonymously: GET /perflocale/v1/languages, GET /perflocale/v1/languages/{slug} and the perflocale/list-languages ability (which stays listed and answers only logged-in users when false).
Default true - the payload is a strict subset of what is already rendered to visitors (switcher, hreflang tags, URL structure), so there is nothing private to protect. Return false to require the read capability (any logged-in user) instead. Write endpoints are unaffected - they always require perflocale_manage_languages.
Parameters
Parameter
Description
bool$public
default true.
WP_REST_Request$request
current request.
Returns
bool
Example2 lines
// Block anonymous reads of /languages.
add_filter( 'perflocale/api/languages_public', '__return_false' );
Gate for the public read endpoint GET /wp-json/perflocale/v1/config (consumed by the Cloudflare Worker / Vercel Edge / Netlify Edge integrations).
Default true - public-read, because the payload is a strict subset of what is already rendered to visitors (URL mode, default slug, active languages, edge-hint header / cookie names). Return false or a WP_Error to require authentication; return true to keep public-read. Symmetric with perflocale/api/languages_public for the /languages endpoint - write endpoints are unaffected and always require perflocale_manage_languages.
If the filter returns anything other than bool / WP_Error, PerfLocale emits a _doing_it_wrong() notice (dev-mode) and falls back to the default public-read behaviour rather than crashing the endpoint.
While anything hooks this filter, and for any logged-in caller, the endpoint answers with Cache-Control: private, no-store instead of the public cache headers, so a shared cache never stores a response the gate might refuse to another client.
Parameters
Parameter
Description
bool|WP_Error$allowed
default true.
WP_REST_Request$request
current request.
Returns
bool|WP_Error
Example11 lines
// Require admin auth to fetch the edge config.
add_filter( 'perflocale/edge_worker/config_permission_callback', static fn() => current_user_can( 'manage_options' ) );
// Allow internal monitoring (header-presented bearer) but block everyone else.
add_filter( 'perflocale/edge_worker/config_permission_callback', function ( $allowed, \WP_REST_Request $request ) {
$bearer = $request->get_header( 'authorization' );
if ( $bearer && hash_equals( 'Bearer ' . MY_EDGE_TOKEN, $bearer ) ) {
return true;
}
return new \WP_Error( 'rest_forbidden', 'Edge config requires auth on this host.', [ 'status' => 401 ] );
}, 10, 2 );
Whether the post-edit Categories / Tags checklist should be filtered to the current language.
Default true when 2+ languages are active. Return false to show every term across every language in the metabox (e.g. for a custom integration that re-scopes terms in JS).
Parameters
Parameter
Description
bool$enabled
-
int$post_id
-
array$args
-
Returns
bool
Example4 lines
add_filter( 'perflocale/admin/filter_terms_checklist', function ( bool $enabled, int $post_id, array $args ): bool {
// Don't filter for editors managing the term taxonomy itself.
return current_user_can( 'manage_categories' ) ? false : $enabled;
}, 10, 3 );
Filter the bundled list of predefined languages shown in the PerfLocale → Languages → Add New quick-select.
The plugin ships with 190+ entries covering every major locale plus regional variants (en-US, en-GB, fr-CA, ar-EG, etc.); use this filter to add custom languages (constructed languages, internal locale variants, niche dialects), prune the bundled set, or replace it entirely with a curated short-list for site editors.
Each entry must be an associative array with these keys:
slug - URL slug. Must match /^[a-z]{2,3}(?:-[a-z]{2,3})?$/ - two or three lowercase letters, optionally followed by a hyphen and two or three more (de, fil, pt-br). No digits, underscores or longer subtags. Must be unique. The shape is enforced both when a language is created and when it is renamed.
locale - WordPress locale (e.g. en_US, fr_FR). Maximum 20 characters, must be unique.
name - English display name shown in the admin picker.
native_name - Native-script display name shown alongside the English name.
flag - ISO 3166-1 alpha-2 country code (lowercase, e.g. us, fr); used to render the flag emoji. Empty string is allowed for languages without an obvious flag.
text_direction - 'ltr' or 'rtl'.
date_format - PHP date() format string. Often locale-specific (e.g. 'j F Y' for European day-month-year).
time_format - PHP date() format string (e.g. 'g:i a', 'H:i').
Notes: This filter only affects the admin quick-select shown when adding a new language. Once a language is saved into wp_perflocale_languages, the filter no longer touches it - existing rows are managed via the regular admin Edit screen or the REST API. Removing an entry from the filter does not remove already-added languages.
Parameters
Parameter
Description
array<int, array<string, string>>$predefined
Bundled languages as loaded from data/languages.php (190+ entries).
Change, reorder or drop the per-language rows of the Translations panel before it renders.
One renderer draws the server-rendered panel - the classic editor meta box, and any host plugin’s own editor screen an add-on mounts it on (Contact Form 7 and WPForms both do) - so a listener here reaches all of those. It does not reach the block editor or Site Editor sidebars: those are React panels fed by the perflocale/v1/translations/post/<id> REST route, which builds its rows independently and applies neither panel filter. Each row carries slug, badge, native_name, is_current, has_translation, translated_id, source_id, source_status and edit_url.
Whether an untranslated row shows a working + Create link is not a row key. PerfLocale decides it once per panel, outside the rows, from the user’s perflocale_translate capability and the post type’s own create capability (see perflocale/object/user_can); a user without both sees a disabled pill instead. A listener here cannot turn the link back on.
The edit_url key is the reason to reach for this filter rather than perflocale/admin/edit_post_link: that one is keyed on post type alone, so it cannot send the same type to different places on different screens. Because $args carries the rendering context, a builder that mounts the panel inside its own editor can keep “Edit” in that editor while the Translations screen still links to the WordPress editor. Returning an empty array suppresses the panel entirely.
Parameters
Parameter
Description
array<int, array<string, mixed>>$rows
One entry per active language.
int$post_id
The object being described.
array<string, mixed>$args
Render arguments, including context.
Returns
array
Example13 lines
add_filter( 'perflocale/panel/rows', function ( array $rows, int $post_id, array $args ): array {
if ( ( $args['context'] ?? '' ) !== 'my-builder' ) {
return $rows; // Leave every other screen alone.
}
foreach ( $rows as &$row ) {
if ( ! empty( $row['has_translation'] ) ) {
$row['edit_url'] = my_builder_edit_url( (int) $row['translated_id'] );
}
}
return $rows;
}, 10, 3 );
Wrap, replace or suppress the finished Translations panel markup.
Runs on the completed HTML string, after perflocale/panel/rows, and reaches the same server-rendered surfaces that filter does - not the block editor or Site Editor sidebars. Reach for perflocale/panel/rows first when you only want to change what the rows say - it keeps PerfLocale’s escaping; use this one when you need to change the markup itself.
⚠️ Wherever PerfLocale echoes the panel, what you return is passed through wp_kses_post() first, so escape your own dynamic values and expect anything outside that allowlist to be removed. Some host screens narrow it further by filtering their whole page through wp_kses() - Contact Form 7 does - so on those screens only that host’s allowlist survives. That pass happens when the panel is echoed, not inside this filter: TranslationsPanel::get_html() returns the markup as your listener left it, so code that fetches it that way must run it through wp_kses_post() before output. Code that hands the string to something that compiles it as an Underscore template (wp.template(), some page-builder controls) must also encode { and }, which kses leaves alone.
Parameters
Parameter
Description
string$html
The panel markup.
int$post_id
The object being described.
array<string, mixed>$args
Render arguments, including context.
Returns
string
Example7 lines
add_filter( 'perflocale/panel/render', function ( string $html, int $post_id, array $args ): string {
if ( $html === '' ) {
return $html; // Nothing rendered; do not invent chrome.
}
return $html . '<p class="description">' . esc_html__( 'Ask the content team before translating.', 'my-plugin' ) . '</p>';
}, 10, 3 );
Filter the capabilities granted to the Editor role on plugin activation.
Return an empty array to prevent Editors from receiving any PerfLocale capabilities. Return a subset to restrict them to specific ones. The Administrator role is not affected.
Note: Capability grants are stored in the database and only written once per version. To re-apply the filter to an existing install, reset the version flag: delete_option( 'perflocale_caps_version' ); then reload any admin page.
Parameters
Parameter
Description
array<string, bool>$caps
Map of capability => grant. Default: perflocale_translate, perflocale_manage_translations, perflocale_approve_translations, perflocale_use_mt.
Returns
array
Example10 lines
// Remove ALL PerfLocale capabilities from the Editor role.
add_filter( 'perflocale/roles/editor_caps', '__return_empty_array' );
// Restrict Editors to translation-only access (no management capabilities).
add_filter( 'perflocale/roles/editor_caps', function ( array $caps ): array {
return [
'perflocale_translate' => true,
'perflocale_use_mt' => true,
];
} );
Filter the default capabilities of the Translator role.
Keys are capability names, values grant (true) or deny (false). A capability added here is granted on the next rebuild of the role; one taken out here is removed on the next rebuild while the role still grants it. Changes a site owner made to the role by hand are kept: a capability the owner removed or denied is not given back, also across deactivation and reactivation, and one the owner added stays.
A rebuild runs when the stored capability version (perflocale_caps_version) is behind the plugin’s: after a plugin update that changes capabilities, on activation, or after that option is deleted. To apply the filter on an existing install, run delete_option( 'perflocale_caps_version' ); once and load any admin page.
Filter the PerfLocale capabilities granted to WooCommerce’s Shop Manager role.
On a site where WooCommerce is loaded and the WooCommerce add-on is not disabled, the role gets these capabilities once, on activation or on the next admin page load, so shop managers can create product translations. What was granted is recorded in the perflocale_shop_manager_caps option, and the grant is not repeated while that record exists, so a capability the site owner removes stays removed. A capability the role already has an entry for, granted or denied, is never changed. Deactivating PerfLocale takes the recorded capabilities off the role, and reactivating puts back those the owner had not removed.
Return an empty array to grant nothing. To run the grant again after changing the filter, delete the perflocale_shop_manager_caps option.
Parameters
Parameter
Description
array<string, bool>$caps
Map of capability => grant. Default: perflocale_translate (machine translation, perflocale_use_mt, is not granted).
Returns
array<string, bool>
Example2 lines
// Do not give shop managers any PerfLocale capability.
add_filter( 'perflocale/roles/shop_manager_caps', '__return_empty_array' );
Filter which WordPress roles have PerfLocale capabilities removed on plugin deactivation or uninstall.
Fires in two places: TranslatorRole::remove_roles() (deactivation) and SiteCleanup::strip_role_and_caps(), which uninstall.php reaches through SiteCleanup::purge_current_site() on both the full-wipe and preserve-data branches.
Parameters
Parameter
Description
string[]$roles
Array of role slugs. Default: ['administrator', 'editor'] on deactivation; ['editor', 'administrator'] on a keep-data uninstall; every role registered on the site (the keys of wp_roles()->roles) on a delete-data uninstall.
Returns
array
Example10 lines
// Do not strip caps from the Editor role on deactivation/uninstall.
add_filter( 'perflocale/roles/cap_roles', function ( array $roles ): array {
return array_diff( $roles, [ 'editor' ] );
} );
// Also clean up a custom role that was granted caps programmatically.
add_filter( 'perflocale/roles/cap_roles', function ( array $roles ): array {
$roles[] = 'shop_manager';
return $roles;
} );
Decide whether the current user may act on one translatable object, when WordPress cannot answer the question itself.
A post type registered with map_meta_cap => false gets no meta-capability mapping, so current_user_can( 'edit_post', $id ) resolves to the type’s primitive capability - and if the host plugin never grants that capability to any role, the answer is false for everybody, administrators included. That is not a permissions failure; the question simply has no answer in core’s model, and only the host knows the real one.
WPForms is exactly that shape, which is why the bundled WPForms addon answers here using WPForms’ own access object rather than inventing a capability on its behalf - a site running WPForms’ Access addon keeps its granular per-form rules. It answers read with the same per-form edit rule as edit, and create with WPForms’ own form-creation capability (create_forms), which WPForms checks without a form ID. Contact Form 7 registers its type the same way but does add the mapping filter, so it needs nothing.
PerfLocale asks with one of four actions. $can is always core’s answer for the matching capability, and $post_id is always an existing post.
edit - may the user edit this object. Asked about a post before PerfLocale lists its translations or links to them, sets its language or creates a translation of it (the Translations panel, the REST routes and the admin Create action); about a translation before a REST update changes it; and, together with read, about the original a new translation is copied from.
delete - may the user delete this translation. Asked by the REST delete route, and by an update that moves a translation to the trash.
read - asked together with edit about the post a new translation would be copied from, when that is not the post the request named. A new translation is copied from the group’s default-language post when it has one, and both answers must be yes. $can is core’s read_post.
create - may the user create a new post of this post’s type. $post_id is an existing post, never the post that will be created. On the paths that create a translation it is the post the translation would be copied from; in the Translations panel it is the post being listed. $can is core’s answer for the type’s create_posts capability (for a pattern, the wp_block type, that is publish_posts). The paths that create ask it only when a translation would actually be created: the REST create route, the admin Create action, the machine-translation routes, the abilities and XLIFF import (see Permissions). The Translations panel asks it about the post it lists, and only for a user who holds perflocale_translate, to decide whether to show a working Create link. A type whose create_posts is not a capability name (Pods sets it to false when a type’s Add New is disabled) is refused without asking this filter.
⚠️ Returning true grants access, so answer only for post types you own, and return $can untouched for everything else. The bundled addons are consulted only when core has already said no, so they can widen access for their own type and can never revoke access core granted. What a widened answer opens depends on who is asking:
The Translations panel requires no PerfLocale capability, so a widened edit shows that object’s translation list and edit links. It offers Create only to a user who also holds perflocale_translate.
The Translations REST routes and the admin Create action require perflocale_translate before asking, so a listener cannot hand translation rights to a user who has none.
The machine-translation routes, the translate-post ability and the bulk translation job (perflocale_use_mt), XLIFF import (perflocale_import_export) and the create-translation ability (perflocale_translate) also check core edit_post on the post they name before asking, so a listener cannot open them to a user core does not already let edit that post.
Answer create with the host’s rule for creating, not its rule for editing: a user who may edit one form is not necessarily allowed to create forms. Answering read with the edit rule is fine, because edit is asked about the same post and must also be yes.
A listener that sends every action other than delete to its edit rule also answers create with that rule, so give create its own answer. A listener that returns $can for actions it does not know leaves read and create to core. On a type whose capabilities no role holds, the case this filter exists for, core refuses both, so the paths that ask them refuse to create translations of that type until the listener answers them: the paths listed under create above, and the bulk translation job when a translation would be copied from a post other than the one it was given.
Parameters
Parameter
Description
bool$can
Whether core’s capability check passed.
string$action
edit, delete, read or create.
int$post_id
The object being acted on (for create, an existing post whose type is asked about; see above).
string$post_type
Its post type.
Returns
bool
Example14 lines
add_filter( 'perflocale/object/user_can', function ( bool $can, string $action, int $post_id, string $post_type ): bool {
if ( $can || 'my_form' !== $post_type ) {
return $can; // Never narrow, and never answer for someone else's type.
}
// Defer to the host's own authority, not to a capability we made up.
return match ( $action ) {
'edit', 'read' => my_plugin_user_can( 'edit_form', $post_id ),
'delete' => my_plugin_user_can( 'delete_form', $post_id ),
// "May create a form at all": $post_id is an existing form, not the new one.
'create' => my_plugin_user_can( 'create_forms' ),
default => $can, // An action you do not know yet stays with core.
};
}, 10, 4 );
Filter the maximum number of translation-link rows held in the autoloaded eager-link-map option (perflocale_eager_links_post / perflocale_eager_links_term).
When the per-type link count is at or below this cap, every prime_translations() call is served from alloptions with zero queries. Past the cap, the map is replaced with a 'too_large' sentinel and the plugin falls back to the per-key cascade in CacheManager - on those sites a persistent object cache (Redis / Memcached) is what keeps the hot path fast.
Default 2000. Lower on memory-constrained hosts where a large alloptions blob hurts more than the extra DB round-trips. Raise on memory-rich hosts that prefer a single autoloaded option over hitting the cache cascade at scale. Receives the ObjectType enum as the second arg so post and term maps can be tuned independently.
Parameters
Parameter
Description
int$cap
(default 2000)
\PerfLocale\Enum\ObjectType$type
-
Returns
int
Example4 lines
add_filter( 'perflocale/cache/eager_map_row_cap', function ( int $cap, \PerfLocale\Enum\ObjectType $type ): int {
// Roomy host: raise the post cap to 5000, keep the term cap at default.
return $type === \PerfLocale\Enum\ObjectType::Post ? 5000 : $cap;
}, 10, 2 );
Defensive byte-size gate for the same eager-link-map option.
After the row cap passes, the serialised array is measured one more time - if it exceeds this byte cap the 'too_large' sentinel is written instead. Catches the edge case of an under-the-row-cap map whose individual rows carry unusually large fields (long language slugs, custom addon columns, etc.).
Default 768000 bytes (750 KB). Tune in tandem with the row cap on hosts with non-default alloptions sizing.
When args_size() for a dispatch is at or above this threshold, the job goes async; below, it runs inline. Defaults per job type: 1000 for string_scan and data_import, 500 for wpml_migration / polylang_migration, 200 for translatepress_migration, 5000 for data_export, 50 for bulk_string_translate, 25 for bulk_translate, 1 for site_translate.
Parameters
Parameter
Description
int$base
(resolved from settings or default)
array$args
(the dispatch args).
Returns
int
Example8 lines
// Force string scans to ALWAYS run async, regardless of file count.
add_filter( 'perflocale/jobs/threshold/string_scan', static fn(): int => 0 );
// Dynamic threshold for data imports: queue big files, inline small ones.
add_filter( 'perflocale/jobs/threshold/data_import', static function ( int $base, array $args ): int {
$file = $args['file_path'] ?? '';
return file_exists( $file ) && filesize( $file ) > 5 * MB_IN_BYTES ? 0 : 999999;
}, 10, 2 );
Whether an identical job already queued or running blocks a new dispatch.
Default true. “Identical” means the same job type, byte-identical arguments and the same site - the logical operation, not merely the type - so unrelated work of the same type still runs in parallel, and a chunked chain (whose cursor advances on every link) is never mistaken for a repeat of itself. When a twin is found, Dispatcher::enqueue() returns that job’s ID with duplicate => true instead of queueing a second one. A twin whose worker stopped without finishing is marked failed instead, and the new dispatch replaces it.
Parameters
Parameter
Description
bool$enabled
-
string$type
-
array$args
-
Returns
bool
Example4 lines
// Let a custom job type be dispatched repeatedly with the same args.
add_filter( 'perflocale/jobs/deduplicate_admission', static function ( bool $on, string $type ): bool {
return 'my_addon_reindex' === $type ? false : $on;
}, 10, 2 );
Maximum array-nesting depth of the args payload accepted by Dispatcher::enqueue().
Default 20. A payload can fit under the byte cap (max_args_bytes) yet still be pathologically deep - deeply-nested arrays trip PHP’s serializer (which WP uses to write option_value) and can blow xdebug.max_nesting_level. Args deeper than this are rejected before serialisation. Floor-clamped to 4, so any legitimate job-args shape is safe.
Parameters
Parameter
Description
int$depth
(default 20, clamped to a minimum of 4).
Returns
int
Example2 lines
// A custom job that ships a genuinely nested config tree.
add_filter( 'perflocale/jobs/max_args_depth', static fn(): int => 40 );
Upper bound on how many queued/running jobs the Resumer re-dispatches in one sweep (e.g. after a deactivate/reactivate).
Default 1000 - far above the active-display cap so pending jobs aren’t silently dropped, yet bounded so a runaway jobs table can’t OOM the sweep. Floor-clamped to 1.
Parameters
Parameter
Description
int$max
(default 1000, clamped to 1 minimum).
Returns
int
Example1 line
add_filter( 'perflocale/jobs/resume_max', static fn(): int => 5000 );
Maximum size of the uploaded JSON import envelope.
Default 536870912 (512 MB). The Export & Import screen sends a file larger than PHP’s upload limits (upload_max_filesize, post_max_size) in pieces, so this filter is the screen’s only size cap. wp perflocale import does not apply it.
Translation groups (trid values) fetched per SELECT during the WPML migration.
The importer first pulls every distinct trid for posts then for terms (cheap, one BIGINT per row), chunks them by this filter, and only fetches the full (trid, element_id, language_code) rows one batch at a time. Lowering the value reduces peak memory on very large sites at the cost of more SQL roundtrips. Default 100. Clamped to 10-1000.
Parameters
Parameter
Description
int$size
-
Returns
int
Example1 line
add_filter( 'perflocale/migration/wpml/batch_size', static fn(): int => 250 );
Number of post_translations / term_translations taxonomy terms fetched per SELECT during the Polylang migration.
The importer first lists every term_id (cheap), chunks them by this filter, and only then fetches the description payload (a serialized PHP array that maybe_unserialize expands into N slots in memory per row). Lowering the value reduces peak memory on very large sites at the cost of more SQL roundtrips. Default 100. Clamped to 10-1000.
Parameters
Parameter
Description
int$size
-
Returns
int
Example1 line
add_filter( 'perflocale/migration/polylang/batch_size', static fn(): int => 250 );
Rows fetched per keyset-paginated batch when importing string translations from WPML's icl_string_translations table.
The importer streams via WHERE s.id > $last_id ORDER BY s.id ASC LIMIT N so peak memory stays bounded at one batch regardless of total row count. Lowering the value trades fewer SQL roundtrips for smaller memory peaks; raising it does the opposite. Default 500. Floor-clamped to 1 (anything lower reverts to the default).
Parameters
Parameter
Description
int$size
-
Returns
int
Example1 line
add_filter( 'perflocale/migration/wpml_string_batch_size', static fn(): int => 1000 );
WPML element types (icl_translations.element_type) the WPML import leaves out.
Each entry is a post_<post type> or tax_<taxonomy> value. The default skips menu items and attachments, which PerfLocale does not group into translation sets, and two internal taxonomies of WPML and WooCommerce. Return an empty array to import every type. Entries of any other shape are dropped.
// Also leave out a custom post type WPML tracked.
add_filter( 'perflocale/migration/wpml/skipped_element_types', static function ( array $types ): array {
$types[] = 'post_acme_log';
return $types;
} );
Rows fetched per keyset-paginated batch when importing gettext string translations from TranslatePress's per-language trp_gettext_* tables.
Streams via WHERE g.original_id > $last_id ORDER BY g.original_id ASC LIMIT N so every row is imported however many gettext strings a language has; lowering the batch size reduces memory at the cost of more SQL roundtrips. Default 1000. Values below 100 revert to the default.
Parameters
Parameter
Description
int$size
-
Returns
int
Example1 line
add_filter( 'perflocale/migration/translatepress/gettext_batch_size', static fn(): int => 2500 );
Minimum TranslatePress translation status to import from its dictionary tables.
Default 1 (machine-translated) so the migration faithfully reproduces what the source site actually displayed; return 2 to import only human-reviewed translations.
Parameters
Parameter
Description
int$status
Default 1 (machine-translated). Set to 2 for reviewed-only.
Returns
int
Example2 lines
// Import only human-reviewed TranslatePress translations.
add_filter( 'perflocale/migration/translatepress/min_status', static fn(): int => 2 );
Whether the TranslatePress import also takes gettext rows that TranslatePress copied from a similar string’s translation (status 3) or from the language pack (status 4).
Default false: only TranslatePress’s own gettext translations (statuses 1 and 2) are imported, so the language pack’s text is not frozen into string overrides.
Strings buffered in memory before flushing to the DB via bulk_insert.
Default 500. Clamped to 50-5000. A $batch_size passed to StringScanner::scan() is only the filter’s default value - a registered callback overrides it for every caller, including the StringScan background job (which always passes an explicit size: 500 unless its batch_size job arg says otherwise).
Parameters
Parameter
Description
int$size
-
Returns
int
Example1 line
add_filter( 'perflocale/strings/scanner/batch_size', static fn(): int => 2000 );
How long a job may sit in queued or running without its updated_at being bumped before the daily GC or the stuck-job watchdog declares it stuck and marks it failed.
Default 6 hours. The resume sweep uses the same window: its follow-up check for a job whose worker lock was still held is scheduled no later than this, and it stops re-checking a job that has sat that long, leaving it to the watchdog.
How long (seconds) a worker caches the queue’s paused/unpaused state in-process before re-reading it.
Default 10. A chatty job emitting hundreds of progress ticks per second would otherwise force a full alloptions reload on every tick just to check the pause flag; this throttle window bounds that to one re-read per window. Lower it for faster pause response at the cost of more option reads; raise it to cut reads further. Floor-clamped to 1. The cache is keyed per blog, so a worker serving multiple blogs on multisite never returns another blog’s pause state.
Upper bound on the delay between type-busy retries.
When the per-type concurrency lock is contended, the re-queue delay grows exponentially with jitter (starting from type_busy_retry_seconds); this caps it so a long-contended type can’t back off to an absurd delay. Default 600s (10 min). This is the per-type analogue of lock_busy_max_seconds, which caps the per-job lock backoff.
Parameters
Parameter
Description
int$seconds
(default 600).
Returns
int
Example1 line
add_filter( 'perflocale/jobs/type_busy_max_seconds', static fn(): int => 1800 ); // 30 min cap
Maximum number of times a worker will re-queue itself when the per-job concurrency lock is held by another worker.
After this many attempts the job is marked failed with a diagnostic message; the operator can manually retry from the Jobs admin page. Distinct from the type-busy retry above - this one trips when the same job_id is being processed by a sibling worker (typically a leaked lock row from a crashed worker that never released). Default 20.
Parameters
Parameter
Description
int$max
-
Returns
int
Example2 lines
// Tighter cap for tenants where a wedged lock should escalate to ops faster.
add_filter( 'perflocale/jobs/lock_busy_max_retries', static fn(): int => 5 );
For tests, custom deployments, or third-party schedulers (Sidekiq, SQS, etc.). Return a JobRunnerInterface instance to bypass the engine setting and Action Scheduler detection.
Dev-mode guard: a return that is neither null nor a JobRunnerInterface instance triggers _doing_it_wrong() and the factory falls back to the engine setting / Action Scheduler auto-detect.
Parameters
Parameter
Description
JobRunnerInterface|null$override
-
Returns
JobRunnerInterface|null
Example3 lines
add_filter( 'perflocale/jobs/runner', static function ( $default ) {
return new MyCustomQueueRunner();
}, 99 );
Maximum size of a .po file accepted by the admin PO importer (PerfLocale › Strings › Import).
Larger uploads are rejected before parsing, because WordPress core's \PO parser accumulates every entry in memory. Gates the admin-UI upload only - the WP-CLI po-import path is not size-capped. Default 50 MB.
Veto a job dispatch before it runs - the kill switch the per-type capability check can't express on its own.
Returning false blocks the dispatch entirely (sync or async); the caller receives ['mode' => 'denied', 'error' => ...]. Returning a string vetoes with that string as the human-readable error message. Returning true (the default) lets the dispatch proceed unchanged. Distinct from perflocale/jobs/threshold/<type>, which only decides sync-vs-async - should_dispatch is yes/no.
Parameters
Parameter
Description
bool$proceed
default true.
AbstractJob$job
the job instance about to dispatch (call $job->get_type() to branch by type).
array$args
the dispatch args (shape varies per job type).
Returns
bool|string. Return false to deny with the default error, or a non-empty string to deny with a custom error.
The wall-clock budget, in seconds, for one chunk of a site-wide translation run.
When the budget is spent the job saves its position and re-queues itself, so a large site is translated across many short requests instead of one that hits the PHP time limit. Default 20. Lower it on hosts with a tight max_execution_time; raising it buys fewer round-trips at the risk of a killed request, which costs more than it saves.
Parameters
Parameter
Description
int$seconds
Default 20; the caller casts the return to float.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/chunk_seconds', function (): int {
return 10; // Shared host with a 30-second ceiling.
} );
The maximum number of post/language pairs one chunk will process, regardless of how much of the time budget is left.
It is the belt to perflocale/jobs/chunk_seconds’ braces: on a fast local database the time budget alone would let a single chunk accumulate a very large batch in memory. Default 500, floored at 1.
Parameters
Parameter
Description
int$pairs
Default 500.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/chunk_max_pairs', function (): int {
return 100; // Smaller batches, lower peak memory.
} );
The wall-clock budget, in seconds, of one slice of a background bulk translation job (bulk_translate).
When the background worker runs the job, a slice stops before its next source-target pair once it has run at least one pair and spent this budget or reached perflocale/jobs/bulk_translate/slice_max_pairs. The job then stores its progress, and the next worker run continues the same job from there, so a host that stops long requests does not stop the job part-way. Default 20 when the worker runs in a web request (WP-Cron or Action Scheduler) and 0 when it runs in a WP-CLI process; 0 or less means no time budget. A bulk translation small enough to run inline is not split, and the site-wide job has its own chunk budget (perflocale/jobs/chunk_seconds).
Parameters
Parameter
Description
float$seconds
Default 20 in a web request, 0 under WP-CLI; the return value is cast to float.
Returns
float
Example3 lines
add_filter( 'perflocale/jobs/bulk_translate/slice_seconds', function (): float {
return 10.0; // Host that stops requests after 15 seconds.
} );
The most source-target pairs one slice of a background bulk translation job runs, a backstop for pairs so fast that the time budget of perflocale/jobs/bulk_translate/slice_seconds never binds.
Default 500 in a web request and 0 under WP-CLI; 0 or less means no pair cap. Under WP-CLI, where a slice has no budget by default, the job frees its runtime caches every 500 pairs instead.
Parameters
Parameter
Description
int$pairs
Default 500 in a web request, 0 under WP-CLI.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/bulk_translate/slice_max_pairs', function (): int {
return 100; // Smaller slices, lower peak memory.
} );
Items the SQLite string-group repair handles per slice. Default 500, kept between 50 and 5000.
Each slice is one transaction: a larger slice means fewer transactions and progress writes, a smaller one a shorter write lock on the database and less work redone after an interruption.
Parameters
Parameter
Description
int$size
Default 500.
Returns
int
Example1 line
add_filter( 'perflocale/strings/sqlite_repair_batch_size', static fn(): int => 200 );
PerfLocale wraps every external dependency (MT provider, webhook receiver, FX sync, geo-IP lookup) in a circuit breaker. After N consecutive failures within a sliding window, the breaker trips OPEN and subsequent calls short-circuit with a typed \PerfLocale\Concurrency\BreakerOpenException - no more piling retries onto a failing dependency, no more visitors waiting for a wedged service. Site Health surfaces every open breaker with a one-click reset; recovery is automatic when the upstream comes back. The hooks below tune trip / window / cooldown thresholds.
Breaker keys follow the pattern mt_<provider_id> (e.g. mt_deepl, mt_wp_ai_client), webhook_<uuid>, fx_sync, and geo_<provider_id> - where <provider_id> is whatever id a site registered through perflocale/geo/providers, since no geo provider ships built in.
When this filter returns true, every breaker becomes a no-op - is_open() always returns false and record_failure() never trips anything. Use to temporarily disable circuit-breaker behaviour while debugging upstream issues, or to ship a hotfix that opts out without rolling back the plugin.
Parameters
Parameter
Description
bool$disabled
Default false.
Returns
bool
Example2 lines
// Disable all breakers (debugging only - re-enable as soon as possible).
add_filter( 'perflocale/breaker/disabled', '__return_true' );
Number of failures within the rolling window that trips a breaker into OPEN.
Global default 5. Per-key variant perflocale/breaker/threshold/<key> overrides for a specific breaker (e.g. perflocale/breaker/threshold/mt_deepl).
Parameters
Parameter
Description
int$threshold
Default 5. Floor 1.
Returns
int
Example5 lines
// Tighten threshold for the MT provider - trip after 2 failures.
add_filter( 'perflocale/breaker/threshold/mt_deepl', static fn(): int => 2 );
// Loosen the global default - tolerate more transient errors.
add_filter( 'perflocale/breaker/threshold', static fn(): int => 10 );
Length of the sliding window (seconds) within which the failure counter accumulates.
A failure older than this resets the counter. Global default 300s (5 minutes). Per-key variant perflocale/breaker/window_seconds/<key>.
Parameters
Parameter
Description
int$seconds
Default 300. Floor 1.
Returns
int
Example2 lines
// Short window for webhook deliveries - only count failures in the last minute.
add_filter( 'perflocale/breaker/window_seconds/webhook_my-uuid', static fn(): int => 60 );
How long the breaker stays in OPEN state before allowing a single probe call (HALF_OPEN).
If the probe succeeds, the breaker closes; if it fails, it re-opens for another cooldown cycle. Global default 300s. Per-key variant perflocale/breaker/cooldown_seconds/<key>.
Dev-mode guard: a non-int return (on either the global or per-key form) triggers _doing_it_wrong() and the breaker falls back to the default 300s cooldown.
Parameters
Parameter
Description
int$seconds
Default 300. Floor 1.
Returns
int
Example5 lines
// Long cooldown for an unreliable third-party FX API - try once per hour.
add_filter( 'perflocale/breaker/cooldown_seconds/fx_sync', static fn(): int => HOUR_IN_SECONDS );
// Quick recovery for your own internal services.
add_filter( 'perflocale/breaker/cooldown_seconds/webhook_internal-uuid', static fn(): int => 30 );
How long the single HALF_OPEN probe holds its turn.
When a breaker’s cooldown expires, exactly one caller is let through to test the dependency; everyone else keeps getting the refusal until that probe reports back. This is the lease that makes “exactly one” true under concurrency, and its TTL is the backstop for a probe that dies without reporting. Defaults to the breaker’s own cooldown, clamped to 30-300 seconds.
Parameters
Parameter
Description
int$seconds
-
string$key
-
Returns
int
Example5 lines
// A provider whose timeout can reach several minutes: hold the turn longer
// so a slow probe is not overtaken while it is still waiting.
add_filter( 'perflocale/breaker/probe_lease_seconds', static function ( int $seconds, string $key ): int {
return 'mt_my_slow_provider' === $key ? 300 : $seconds;
}, 10, 2 );
Not a hook but the typed companion to the breaker filters above. When a breaker is OPEN, AbstractProvider::make_request() throws this instead of firing the HTTP call. Catch it to route the request to a graceful-degradation path (a cached translation, a "service unavailable" UI, leaving the source text in place) without conflating with genuine downstream errors.
Extension points for the data-export / data-import lifecycle. Pair the export/sections filter with the matching import/section/<name> action to round-trip your addon's own data through wp perflocale export / import.
Register one or more top-level sections that your addon wants to add to the exported JSON envelope (siblings of data, settings, roles).
Each entry in the returned array is written as "<section_name>": <json> at the envelope's top level. Reserved core keys (perflocale_export, version, format_version, exported_at, site_url, sections, settings, addon_settings, disabled_addons, roles, data) cannot be overwritten and are silently dropped if a callback tries.
Parameters
Parameter
Description
array$sections
existing sections (empty by default). Keyed by section name; each value is JSON-serialisable.
array$context
{ requested: string[], format_version: int }. requested is the user-chosen section list (skip your section if it's not requested); format_version is the envelope schema version (currently 1).
Returns
array<string, mixed>.
Example13 lines
// Acme Reviews addon: ship its rows in every export.
add_filter( 'perflocale/export/sections', static function ( array $sections, array $context ): array {
global $wpdb;
$rows = $wpdb->get_results(
"SELECT id, post_id, language_id, body FROM {$wpdb->prefix}perflocale_addon_acme_reviews",
ARRAY_A
);
$sections['acme_reviews'] = [
'schema_version' => 2,
'rows' => $rows ?: [],
];
return $sections;
}, 10, 2 );
Whether a data import's own writes start Sync Fields mirroring. Default false.
An import (Settings → Export & Import, or wp perflocale import) writes each post the value the file holds for it, in Merge and in Replace: a value it writes on one member of a translation group is not copied to the other members, so the import reproduces the file.
Return true to have the import's writes mirror as a save does: when Sync Fields lists the key (after a Replace, the file's own Sync Fields list), the value is copied to the post's translations. The import then no longer reproduces the file exactly: translations can get values the file does not give them, a Merge can change values this site already had, and translations that the exporting site held back (marked as needing an update) are overwritten. Only _perflocale_seo_exclude is both carried by an export and allowed in Sync Fields today.
Rows the data importer reads and checks per piece. Default 500, kept between 50 and 5000.
The importer reads an export file a piece at a time: for each piece it runs one look-up query per unique key of the table, so rows that would collide are skipped before any write, and then inserts the rows. Larger pieces mean fewer look-ups; smaller ones keep peak memory lower. wp perflocale import --batch-size (and network-import) sets it for one run.
Parameters
Parameter
Description
int$size
Default 500.
string$table_name
Table being imported, for example translation_links.
The settings a Replace import keeps as this site has them.
These settings describe the site rather than its translation data: its per-language domains, its machine-translation endpoints, its uninstall choice and its paused background queue. A Replace import applies the file's other settings and leaves these as they are; the import report names each one whose value in the file was different. Return an empty array to apply every setting from the file. API keys are never in an export file, so they are always kept.
// Moving a site to a new domain set: take the domains from the file too.
add_filter( 'perflocale/import/keep_target_settings', static fn( array $keys ): array => array_values( array_diff( $keys, [ 'language_domains' ] ) ) );
The size of each piece in which the Export & Import screen sends an import file larger than PHP accepts in one request.
Lower it when a proxy or firewall in front of the site refuses requests smaller than PHP's own limits; the screen also halves a piece the server refuses as too large, down to 256 KB. The result is kept between 256 KB and 8 MB and at most three quarters of the smaller of PHP's upload_max_filesize and post_max_size.
Parameters
Parameter
Description
int$bytes
Default: three quarters of the smaller of PHP's upload limits, between 256 KB and 8 MB.
Returns
int
Example2 lines
// A firewall that refuses request bodies over 1 MB.
add_filter( 'perflocale/import/upload_piece_bytes', static fn(): int => 1024 * 1024 );
Short-circuit a single (source post, target language) row inside the bulk-translate job before the default TranslationService::translate_post() call.
Mirrors WordPress's pre_* filter convention: return null (the default) to let the regular flow run; return any other value to skip the default call and treat the returned value as this row's result.
Return [ 'post_id' => int ] - counted as created.
Return any other array, string, or false - counted as skipped (the after_translate action still fires, with short_circuited => true).
Return null - default flow runs.
Parameters
Parameter
Description
mixed$pre
null by default.
int$source_id
source post ID.
string$target_slug
target language slug (e.g. de, ar-EG).
array$context
{ source_id, target_slug, target_id, provider, processed, total }. provider is the configured MT provider key (deepl / google / etc.). processed / total let you log progress.
Returns
mixed (see semantics above).
Example14 lines
// Skip MT on posts tagged "do-not-translate".
add_filter( 'perflocale/mt/bulk/before_translate', static function ( $pre, int $source_id, string $target_slug, array $ctx ) {
if ( has_term( 'do-not-translate', 'post_tag', $source_id ) ) {
return 'skip'; // counted as skipped, no provider call made
}
return $pre;
}, 10, 4 );
// Route Arabic translations through a different provider by claiming a result.
add_filter( 'perflocale/mt/bulk/before_translate', static function ( $pre, int $source_id, string $target_slug, array $ctx ) {
if ( $target_slug !== 'ar' ) { return $pre; }
$post_id = my_addon_translate_via_custom_arabic_provider( $source_id );
return $post_id ? [ 'post_id' => $post_id ] : 'skip';
}, 10, 4 );
Hard ceiling on how many strings a single Bulk-Translate Strings dispatch (Strings admin page → bulk MT toolbar) will touch.
Default 5000. The cap applies to all three selection modes - an explicit ID list, a filtered set, or all strings - so a misclick on a 100k-row table can’t turn into one runaway provider bill. Raise it only if you genuinely intend to translate more strings in one run, and have the provider quota/budget for it.
This is not the same as the per-job Auto-mode cutoff - that’s perflocale/jobs/threshold/<type>, which decides whether a dispatch runs inline or async based on args_size() (string_count × target_count). This filter instead bounds how many strings the dispatch resolves in the first place. There is no UI/settings field for this ceiling; it is code-only.
Parameters
Parameter
Description
int$cap
the ceiling, 5000 by default. The return value is cast to int.
Returns
int - the maximum number of strings a single dispatch may translate.
Example2 lines
// Roomy host with budget: allow up to 20,000 strings per bulk dispatch.
add_filter( 'perflocale/mt/bulk_string_max_per_dispatch', fn() => 20000 );
Sites fetched per iteration when PerfLocale walks a multisite network - on network activation (perflocale.php), on the network deactivation sweep (src/Deactivator.php) and on the uninstall sweep (src/Database/SiteCleanup.php).
One number tunes all three.
Parameters
Parameter
Description
int$chunk
Default 100. Floored at 1 on activation; the deactivation and uninstall sweeps fall back to 100 for a non-numeric return.
What a Polylang import does when PerfLocale already has another language for an imported post or term: keep it (default) or take Polylang's.
keep (default) leaves PerfLocale's language and lists the item. source sets Polylang's language and lists the change, with no record of the old value. Neither value moves another post or term out of its translation group or takes its place: those sets are listed and left as they are under both values, and so is a post or term that Polylang has in the default language.
A post or term alone in its translation group, with a language no import wrote, has no language yet: both values give it Polylang's, the default language included. When that replaces a language other than the default, the change is listed.
Read once per object type when an import starts; any value other than source counts as keep. Put it in a plugin or must-use plugin (an import from the settings screen runs in its own request) and remove it after the move.
Programmatic override for whether the edge-integration feature is active.
When returned true, PerfLocale publishes /wp-json/perflocale/v1/config and honours the edge_hint detection method, regardless of the edge_integration_enabled setting value.
Programmatic override for JSON-LD schema enrichment across the six built-in SEO addons (Yoast, AIOSEO, Rank Math, SEOPress, Slim SEO, The SEO Framework).
Default is true; return false to suppress inLanguage + workTranslation additions.
Per-user hourly ceiling on machine-translation requests.
All three MT entry points share this budget: POST /perflocale/v1/machine-translate, POST /perflocale/v1/block-translate, and the perflocale/translate-post Ability (which is itself reachable over REST and MCP). Return 0 to disable the per-user cap only - the site-wide cap from perflocale/mt/rate_limit_site still applies and will still return HTTP 429. Throttling stops entirely only when both filters return 0.
Site-wide hourly ceiling on machine-translation requests, summed across every user and across all three MT entry points.
Pairs with perflocale/mt/rate_limit (per-user). The per-user and site counters share a single global lock so a hostile editor with the MT capability can't fan out parallel requests to drain the site-wide budget faster than the rate-limit check can register them; a request that cannot take that lock is refused rather than admitted, so contention cannot be used to slip past the cap.
Parameters
Parameter
Description
int$limit
Default 5000 requests per hour, summed across all users. Set to 0 to disable the site cap.
Returns
int
Example5 lines
// Tighten the global ceiling for a shared budget across an editorial team.
add_filter( 'perflocale/mt/rate_limit_site', fn() => 2000 );
// Disable the site cap entirely (per-user cap still applies).
add_filter( 'perflocale/mt/rate_limit_site', fn() => 0 );
Tune the data-quality gate that every import passes BEFORE any database write.
The gate rejects invalid UTF-8, null bytes, cardinality bombs, oversized values, and malformed rows - errors name the exact JSON path so the operator can fix the file.
Only the core sections and the sections of an add-on that hooks perflocale/import/section/<name> are read; any other top-level section is walked over without being kept and named in the report as not imported. The same limits apply to wp perflocale network-import. A file over a limit is refused before anything is written.
Parameters
Parameter
Description
array$limits
Keys: max_languages (default 500), max_rows_per_table (default: the rows that fit in PHP’s memory_limit: a Merge keeps about 512 bytes per row of the file’s largest table, using up to 80% of the memory free when the import starts; never fewer than 500,000 and never more than the import file ceiling divided by 3; with no memory limit (-1, as under WP-CLI) it is that file ceiling. A file over it is refused before anything is written), max_value_bytes (default 1,048,576), max_section_bytes (default 33,554,432: the largest single row and the largest add-on section the importer reads), max_config_bytes (default 4,194,304: the largest of the file’s own top-level keys, such as settings, roles and addon_settings, which hold settings and lists, never content), max_captured_bytes (default 67,108,864: the most the importer keeps in all while it reads a file’s structure, that is everything outside the table rows), max_top_level_members (default 1,000: the most top-level sections a file may have), max_data_members (default 100,000: the most tables and row lists in the file’s data section; for wp perflocale network-import, the most sites).
Limits on uploads of import files in progress (per user and for the whole site).
The Export & Import screen sends a file larger than PHP accepts in one request in pieces, staged on the server until it is imported. An upload counts from its start until it is imported, cancelled or fails, or until an hour passes without a piece. Bytes are the sizes announced at the start. A start that would pass a limit is refused with a message on the screen. A missing key keeps its default; every value is at least 1.
Parameters
Parameter
Description
array$quota
Keys: user_uploads (default 3), user_bytes (default twice perflocale/import/max_file_bytes: 1 GB), site_uploads (default 10), site_bytes (default four times perflocale/import/max_file_bytes: 2 GB).
Returns
array
Example5 lines
// A site with many administrators who import at the same time.
add_filter( 'perflocale/import/upload_quota', static function ( array $quota ): array {
$quota['site_uploads'] = 20;
return $quota;
} );
Outward-facing dispatches fail closed on non-production environments: staging and development clones carry the production webhook URLs in their cloned database, so without the gate a clone silently fires production endpoints.
Managed hosts (WP Engine, Kinsta, etc.) set WP_ENVIRONMENT_TYPE=staging on clones automatically; WordPress defaults to production when unset, so ordinary single-environment sites are unaffected. Return true to opt a surface back in.
Parameters
Parameter
Description
bool$allow
Default false.
string$feature
Dispatch surface. Currently only 'webhooks'.
string$environment
Current wp_get_environment_type() value.
Returns
bool
Example4 lines
// QA environment that owns its own webhook endpoints - allow webhooks there.
add_filter( 'perflocale/dispatch/allow_non_production', function ( bool $allow, string $feature ): bool {
return $feature === 'webhooks';
}, 10, 2 );
PerfLocale ships a layered GC system so every plugin-owned data store stays bounded. These filters tune the retention windows; the matching do_action hooks fire after each GC pass (see the Actions section below).
Number of days a perflocale_strings row can go un-rediscovered before the daily mark-and-sweep GC deletes it (and cascades to the matching perflocale_string_translations rows).
Every StringRepository::bulk_insert() call (the scanner) and every register_setting_string() call touches the row's last_seen_at, so strings that are still in plugin / theme code or actively registered never expire. The sweep is armed by the string scan, not by the clock: the GC deletes nothing unless a complete string scan finished within the retention window (recorded in the perflocale_strings_last_full_scan option, and deliberately left un-armed when any scan target was skipped). On a site that never runs a full scan, strings whose source disappears (uninstalled plugin, removed theme, deleted code) are kept indefinitely rather than aged out - fail-safe, so a never-scanned corpus imported from PO or a migration is never deleted.
Parameters
Parameter
Description
int$days
Default 90. Set to 0 to disable the GC.
Returns
int
Example5 lines
// Hold on to potentially-stale strings for a full year.
add_filter( 'perflocale/strings/stale_retention_days', fn() => 365 );
// Disable the strings GC entirely (rows accumulate until manually deleted).
add_filter( 'perflocale/strings/stale_retention_days', fn() => 0 );
Allowlist of context values that the strings GC will never delete, even when last_seen_at exceeds the retention window.
Empty by default. Use it for manually-registered strings whose register_setting_string() code path may not run regularly - settings labels, addon-registered templates, and the like.
Parameters
Parameter
Description
string[]$contexts
Default [].
Returns
array
Example6 lines
// Preserve a custom-addon setting label that's only re-registered when the
// addon's admin page is rendered (might be longer than 90 days between hits).
add_filter( 'perflocale/strings/manual_contexts', function ( array $contexts ): array {
$contexts[] = 'my_addon_settings_label';
return $contexts;
} );
Whitelist specific theme menu locations that should receive the language switcher.
Parameters
Parameter
Description
array$locations
the theme location slugs that should receive the switcher. Defaults to the locations ticked on Settings → Language Switcher (“Append to classic menus”), kept verbatim so mixed-case slugs like headerMenu stay intact; return a wider or narrower set to override, or an empty array to allow every menu.
Filter the wp_kses-style allowed-tags map applied to translated HTML returned by MT providers.
The default is derived from core rather than hand-maintained: wp_kses_allowed_html( 'post' ) minus object, textarea and button, plus source (which core omits but the core/video and core/audio blocks emit). Because it tracks core, it doesn't silently strip block markup the way a fixed list does. Tighten or loosen from there.
Meta-key fragments that are never copied onto a translation nor sent to machine translation - credentials, tokens and other secrets.
Defaults: _password, _secret, _token, _api_key, _apikey, stripe, paypal, _credentials, _encrypted, _auth. Despite the name these are not regular expressions, and the pattern’s own text matches in any case. Wrapping a pattern in regex delimiters makes it match nothing.
The defaults match whole parts of the key. A default starts at the start of the key, after a character that is not a letter or digit (a leading _ is that character), or at a capital letter that follows a lower-case one, as in camelCase. It ends at the end of the key or at such a boundary, and before that end any sequence of these may follow it directly: a plural s, digits, the words key, token, secret, hash, salt, code and id, and the auth forms authorize, authorization and authentication. The pattern and these words match in any case, but only a change from lower case to upper case counts as a boundary. So _auth matches api_auth, _auth_token, _authkey, _authTokenHash and _authorize_net_login_id, and stripe matches _stripe_customer_id, stripeSecretKey, wcStripeKey and stripetokenid, but book_author, _author_bio, _authorBio, _AUTHOR, _authority and pinstripe_color are ordinary keys.
Any other entry, such as one you add, matches anywhere in the key (a substring match), so _my_encrypted_ also matches _my_encrypted_v2.
The same list applies in three places:
the post-meta copy made when a translation is created;
content sync of seed-only keys: a matching key is not synced to any language (keys you mirror through Sync fields or perflocale/sync/mirror_meta_keys are your explicit choice and are not checked);
machine translation of meta: a matching key is removed from the perflocale/mt/translatable_meta_keys list, so it is never sent to a provider or overwritten.
The filter runs each time one of these operations starts, so its answer can differ per site on multisite. Entries that are not strings are ignored.
Parameters
Parameter
Description
array$patterns
key fragments: the defaults matched as whole key parts, other entries as substrings in any case.
string$context
Which list: copy (meta copied onto a translation) or machine_translation (meta sent to machine translation; that list adds token, secret, api_key, apikey, password, license and licence). Passed to callbacks that accept two arguments.
Returns
array
Example5 lines
add_filter( 'perflocale/translation/dangerous_meta_patterns', function ( array $patterns ): array {
// Substring, not regex - matched case-insensitively anywhere in the key.
$patterns[] = '_my_encrypted_';
return $patterns;
} );
Switch PerfLocale's WordPress Abilities API integration off, or force it on.
The Abilities API ships in WordPress 6.9 and exposes plugin operations as discoverable, REST-callable, AI-tool-friendly abilities. PerfLocale registers six abilities in the perflocale-translation category. The four read-only abilities register by default. The default is the abilities_enabled setting (PerfLocale → Settings → Advanced → AI Agent Abilities), so it can be switched off from the admin as well as from code. The two abilities that WRITE - perflocale/create-translation and perflocale/translate-post - stay off until you opt in separately via perflocale/abilities/write_enabled or its checkbox.
Once enabled, the following abilities register on the wp_abilities_api_init action and become accessible via wp_get_abilities(), the /wp/v2/abilities REST endpoint, and any tool that consumes the registry (Claude Desktop, Cursor, MCP-aware integrations):
perflocale/list-languages - return all configured languages.
perflocale/get-translations - fetch the translation group for a post.
perflocale/detect-language - report which language a post or term is assigned to.
perflocale/convert-url - convert a URL between languages.
perflocale/translate-post - machine-translate a post into a target language.
perflocale/create-translation - create a new translation linked to an existing post.
On WordPress 6.8 and earlier the Abilities API isn't available; this filter has no effect because the registration callback never fires.
Parameters
Parameter
Description
bool$enabled
Defaults to the abilities_enabled setting, which ships on.
Returns
bool
Example2 lines
// Turn the integration off entirely (it is on by default).
add_filter( 'perflocale/abilities/enabled', '__return_false' );
Toggle PerfLocale's block-editor toolbar extension: the Translate dropdown on text blocks (machine-translate, fill in from the source translation, show only for certain languages, and the do-not-translate marker).
Upper bound on the number of published posts/pages loaded when PerfLocale draws the per-language badges on the Appearance → Menus screen (the language codes appended to items in the “Add to Menu” metabox).
Rows are ordered by post_modified DESC, so the most recently edited content keeps its badge on very large sites.
Parameters
Parameter
Description
int$limit
Default 5000. 0 means unlimited (the LIMIT clause is dropped entirely - use carefully).
Override the set_time_limit() value used by the two bulk admin AJAX handlers that raise it: Create taxonomy translations (perflocale_create_taxonomy_translations) and Assign post languages (perflocale_assign_post_languages).
The Create WooCommerce page translations handler does not change the time limit, so this filter has no effect there. Both handlers are gated by check_ajax_referer() + current_user_can('manage_options'), and the time-limit raise happens only AFTER those gates pass. Default 0 (no limit). Set a positive int (seconds) if your host enforces a hard cap and you'd rather error out cleanly than have the handler abort mid-batch.
Parameters
Parameter
Description
int$seconds
Default 0 (no limit).
Returns
int
Example2 lines
// Cap the bulk admin handlers at 10 minutes on this host.
add_filter( 'perflocale/admin/bulk_time_limit', fn(): int => 600 );
Fraction of PHP’s memory_limit at which the bulk admin handlers stop the current run and hand back a resume cursor instead of continuing.
Default 0.8; clamped to between 0.5 and 0.95, so a filter cannot disable the guard. Lower it on a host that reports a generous limit it cannot actually honour.
Upper bound on the rows-per-page value any PerfLocale list screen will honour.
This is a resource bound, not a cosmetic one. Every row on the Translations screen contributes an ids[] checkbox to the bulk-action POST, and PHP silently drops form fields past max_input_vars - so a page size larger than the server can receive back makes a bulk action run on an arbitrary prefix of the operator’s selection and report success for it. Raise it only for a screen that does not post one field per row, and only after checking the host’s max_input_vars. The option name is passed so different screens can carry different ceilings.
Parameters
Parameter
Description
int$ceiling
Computed ceiling: max( 10, floor( max_input_vars * 0.4 ) ), or 400 when max_input_vars is unset. The return is floored at 1.
string$option
The per-page option being read (e.g. perflocale_strings_per_page).
Returns
int
Example3 lines
add_filter( 'perflocale/admin/per_page_ceiling', function ( int $ceiling, string $option ): int {
return 'perflocale_strings_per_page' === $option ? 500 : $ceiling;
}, 10, 2 );
Wall-clock budget for one run of Assign Default Language.
When it is spent the handler returns a cursor and the browser continues from there, so a large site finishes across several requests rather than hitting a timeout. Default 15.0.
Whether the WordPress Abilities API registration includes the WRITE abilities (creating and updating translations) as well as the read-only ones.
Mirrors the Advanced setting of the same name, so a site can keep the setting off and still enable writes for one environment from code, or force it off regardless of the setting.
Capability required to register a webhook via POST /perflocale/v1/webhooks.
The default is manage_network_options on multisite, so only network administrators can register one there, and manage_options on single site. Listing and deleting webhooks stay at manage_options. A filter that returns an empty string falls back to the default rather than opening the route.
Parameters
Parameter
Description
string$capability
-
WP_REST_Request$request
-
Returns
string
Example5 lines
// On a network that deliberately delegates webhook creation
// to subsite administrators.
add_filter( 'perflocale/webhooks/register_capability', function ( string $capability, $request ): string {
return 'manage_options';
}, 10, 2 );
Verdict on whether a webhook URL may be used - return false to reject.
It runs in two places: when a webhook is registered (POST /perflocale/v1/webhooks, which then answers 400 unsafe_url naming the reason) and again before every delivery attempt. The built-in address checks block private, shared, reserved, multicast, loopback and metadata addresses; use this filter to layer org-specific policy on top.
It is not the first gate. Four syntactic rules run before the filter and return early, so a filter cannot re-allow them: an unparseable URL, a scheme other than http / https, credentials embedded in the URL (user:pass@), and a port outside core's http_allowed_safe_ports (80, 443, 8080 - PerfLocale reads core's own filter, so widening it for WP_Http widens it here too). WordPress refuses those URLs at request time anyway, so they are refused at registration rather than stored as a webhook that could never deliver.
Fires when a post’s public readability changes - published to private, private to published, trashed, restored - carrying every front-end URL affected by that change: the post’s own permalink and the permalink of every translation in its group.
This exists because a translated page is a separate post with its own language-prefixed URL, and a full-page cache generally does not know those URLs exist. Worse, a cache that purges by permalink computes it during an admin or CLI request, where PerfLocale deliberately does not add the language prefix - so the URL it purges is not the URL it stored, and a page that has just been made private can stay publicly readable in the cache until it expires.
PerfLocale resolves the real URLs and publishes them. It deliberately calls no cache plugin itself: several per-post purge APIs go on to purge a CDN or Varnish over the network, and a translation plugin should not silently add outbound HTTP to somebody else’s save. With no listener this hook costs nothing.
Purge only the URLs you are given. A full cache flush on every visibility change is exactly the behaviour this hook exists to avoid.
Parameters
Parameter
Description
array$urls
Absolute front-end URLs, language prefixes included.
int$post_id
The post whose visibility changed.
Example5 lines
add_action( 'perflocale/cache/purge_urls', function ( array $urls, int $post_id ): void {
foreach ( $urls as $url ) {
my_page_cache_purge( $url );
}
}, 10, 2 );
Fires when a long-running process (a background job or WP-CLI command) finds data another process changed and drops its in-memory copies.
A long-running process checks at its checkpoints whether another request changed PerfLocale's data. When it did, PerfLocale drops the copies this process holds in memory and fires this action, so an extension can drop its own copies too.
Parameters
None
Example3 lines
add_action( 'perflocale/cache/refreshed', static function (): void {
My_Addon_Cache::reset();
} );
Fires after the plugin code version changes (e.g., updated from 1.0.0 to 1.1.0).
Unlike perflocale/upgraded which fires on DB schema changes, this fires on any code version bump. Useful for version-specific non-DB tasks like migrating settings or regenerating files.
Fires when the current language is temporarily swapped for an internal rendering window - for example, rendering an order email in the customer’s locale.
Only fires on an actual change (a same-language no-op does not fire it).
Fires on multisite when restore_current_blog() puts back a current language other than the one in effect on the blog being left.
Code that resolved something against the current language (a translated menu, template or option value) re-reads it here. It does not fire when the language stays the same, or on sites without blog switches.
Parameters
None
Example3 lines
add_action( 'perflocale/language/restored', static function (): void {
my_plugin_forget_language_cache();
} );
Fires after a language slug is renamed (e.g. en → en-us).
Old URLs are 301-redirected to the new slug; this hook lets integrations sync downstream caches, search indices, or external systems that referenced the old slug.
Fires after the Languages-list reorder UI persists a new sort_order for one or more rows - via drag-and-drop, the drag handle’s arrow keys, or (below 782px, where the handle is hidden) the up/down buttons.
All three route through the same languages/reorder REST endpoint.
Parameters
Parameter
Description
array$ordered_ids
Language IDs in their new visual order.
int$offset
Starting position (0-based) - sort_order values run $offset + 1 … $offset + count($ordered_ids). Lets the caller renumber a paginated slice without disturbing rows on other pages.
Fires after a translation post or term is created.
Parameters
Parameter
Description
int$new_id
New post/term ID.
string$type
Object type ('post' or 'term').
string$target_slug
Target language slug.
int$source_id
Source post/term ID the translation was created from. Useful for correlating analytics, CRM entries, or webhook payloads without a secondary translation-group lookup.
Example6 lines
add_action( 'perflocale/translation/created', function ( int $new_id, string $type, string $target_slug, int $source_id ): void {
if ( $type === 'post' ) {
// Correlate source → translation, e.g. send to CRM / analytics.
my_crm_record_translation_pair( $source_id, $new_id, $target_slug );
}
}, 10, 4 );
Fires when one or more of the post-meta / featured-image / taxonomy-term copy steps throws while creating a translation.
The translation post and its group link are still created - a linked translation with missing meta is recoverable via re-sync, whereas rolling back would lose the translator’s work - so this is a notification signal, not an error you need to recover from. The same failures are also written to the _perflocale_meta_copy_errors post meta (surfaced as a per-post admin notice); this hook lets integrators forward the signal to Slack, email, or an error tracker.
Parameters
Parameter
Description
int$new_post_id
the newly-created translation post ID.
int$source_id
the source post the translation was created from.
string$target_slug
target language slug.
array$errors
one entry per failed step: { step: 'post_meta'|'featured_image'|'taxonomy_terms', message: string }.
Fires when a translated slug could not be written.
This is not fatal - the object keeps its untranslated slug and its URL still resolves - but without this hook the failure is invisible: set_slug() returns false and most callers have nothing useful to do with it. Use it to tally failures during a bulk import, or to alert when a table is broken. A matching line is also written to the error log, but only when WP_DEBUG is on, so that a broken table during a 10,000-term import cannot flood a production log.
Parameters
Parameter
Description
string$reason
one of:
'empty_slug' - the slug sanitised down to nothing (for example a title made entirely of punctuation or of characters sanitize_title() strips).
'duplicate_exhausted' - the slug and all 99 -2…-100 suffixes are already taken in that language. Realistically only reachable on a pathological data set.
'db_error' - the write itself failed (missing table, permissions, disk).
array$context
object_type, object_subtype, object_id, language_id, slug (the slug that was attempted) and, for db_error, db_error with the database message.
Example8 lines
add_action( 'perflocale/slug/write_failed', function ( string $reason, array $context ): void {
if ( $reason === 'db_error' ) {
my_alerting_channel( sprintf(
'PerfLocale could not write a slug for %s #%d: %s',
$context['object_type'], $context['object_id'], $context['db_error']
) );
}
}, 10, 2 );
Fires after the Translations-page bulk “Mark as Needs Update” action flips translation_links.status to needs_update for one or more (source_post, target_lang) pairs.
Use this to dispatch translator notifications or push staleness flags to an external system.
Parameters
Parameter
Description
array$source_ids
Source post IDs.
array$target_lang_ids
Target language IDs (single ID or all active languages).
int$count
Number of sibling translation_links rows the action matched in the target languages, not the number MySQL reported as changed. A row already sitting at needs_update is matched but not changed, and reporting the changed count would tell the operator “0 translations flagged” over a selection that did match. 0 also means the write failed - the caches are deliberately left alone in that case, because the stored statuses are still the cached ones.
Fires when one or more meta values could not be machine-translated.
The post translation itself is unaffected - failed keys keep their source value and the reason is stored in the _perflocale_meta_mt_errors post meta on the translation.
The WooCommerce addon fires it too when a product’s variation descriptions or custom attribute options could not be translated, with [ '_variation_description', '_product_attributes' ] as $keys.
Fires after machine translation wrote meta values to a translation, once per run, and only when at least one value changed.
Keys whose stored value already was the translation are not listed. It fires before perflocale/mt/meta_translate_failed reports any failure from the same run. Use it to drop anything your code built from the old values, such as a cached render. PerfLocale sends the translation’s page-cache purge signal from it, and the ACF addon clears ACF’s stored values for that post.
Parameters
Parameter
Description
int$translation_id
Translation post ID (the post written to).
string[]$written_keys
Meta keys whose value was written.
int$source_id
Source post ID.
Example5 lines
add_action( 'perflocale/mt/meta_translated', function ( int $translation_id, array $written_keys, int $source_id ): void {
if ( in_array( '_my_product_tagline', $written_keys, true ) ) {
delete_transient( 'my_tagline_render_' . $translation_id );
}
}, 10, 3 );
Fires when machine translation fails with an exception.
It also fires in one more case after the provider has answered: PerfLocale went to create the translation and found that one already existed in the target language, for example because another request created it after PerfLocale last checked. That translation is left as it is rather than overwritten with the machine translation, and the exception message says so.
Fires after each theme/plugin string is machine-translated and its row saved.
Lets third-party code mark the row for review, push it into an external review queue, or log MT activity. Fires once per string per target language during a bulk string-translation job.
Parameters
Parameter
Description
int$string_id
the source string ID.
int$target_lang_id
the target language ID.
string$translation
the machine-translated text just saved.
string$source
the original source text.
Example4 lines
add_action( 'perflocale/mt/string_translated', function ( int $string_id, int $target_lang_id, string $translation, string $source ): void {
// Queue the freshly machine-translated string for human review.
my_review_queue_add( $string_id, $target_lang_id );
}, 10, 4 );
Fires after full-mirror meta keys have been written to a sibling translation, and only when at least one was actually written.
Its purpose is generated artefacts a raw meta write cannot reach on its own: an add-on can drop the sibling’s compiled CSS or cached markup so it is rebuilt from whatever was just written, instead of continuing to serve something built from the previous value. PerfLocale’s bundled Elementor, Bricks, Beaver Builder, Oxygen and Oxygen 6 add-ons all listen. Those add-ons do not mirror their layout documents (see perflocale/sync/mirror_meta_keys), so their handlers fire only for the text-free keys that do mirror - and Elementor’s, which gates on _elementor_data, does not fire at all unless someone registers that key as a mirror key deliberately. They are a safety net for exactly that case.
Parameters
Parameter
Description
int$source_id
Source post ID.
int$target_id
Sibling (target) post ID.
array<int, string>$mirror_keys
Mirror meta keys just written to the sibling.
Example4 lines
add_action( 'perflocale/sync/after_mirror', function ( int $source_id, int $target_id, array $mirror_keys ): void {
// Drop the sibling's generated CSS so it rebuilds from what was just written.
delete_post_meta( $target_id, '_elementor_css' );
}, 10, 3 );
Fires after the per-object translation-link caches (L1 static memo, L2 group generation, and L3 transient envelopes) are flushed - e.g. on a wholesale change such as a language deletion that removes link rows without enumerating each affected sibling object.
Hook it to invalidate any external store keyed off translation groupings.
(the worker's untruncated return value; the copy stored on the job row is replaced with a small truncation stub when it serialises over MAX_RESULT_BYTES = 64 KB).
Example3 lines
add_action( 'perflocale/jobs/completed', static function ( string $job_id, string $type, array $result ): void {
// Push to your metrics pipeline...
}, 10, 3 );
Fires when a background job fails in its worker: the worker threw an exception and the retry-with-backoff is about to be scheduled (or skipped because the attempt cap has been hit); the job reported a run that did nothing but fail, as a bulk string translation or an import can, and $e is a RuntimeException carrying its reason; or the progress of a job that runs in slices could not be stored.
Receives the FULL untruncated throwable, useful for monitoring - the version persisted to the job row is path-redacted and truncated.
It does not fire when PHP itself stops inside the worker (a fatal error such as running out of memory, or an exit), nor when the stuck-job watchdog or Action Scheduler marks failed a job whose worker is gone. Those jobs show failed with their reason on the Jobs page and in GET /jobs/{id}.
Fires when a long-running worker cooperatively aborts itself in response to an operator cancel mid-flight.
It also fires when the cancel is found only after the worker’s last progress tick, or between two slices of a job that runs in slices. Distinct from perflocale/jobs/failed because it isn't an error - useful for distinguishing operator-canceled from worker-errored in dashboards.
Lifecycle hooks for the data-export / data-import flow. Pair with the Export & Import filters to round-trip addon-shipped data through wp perflocale export/import.
Fires inside DataExportJob::execute() after the export file has been successfully written to disk, BEFORE the job result is stored on the JobState row.
The file is still readable at $path when this action fires - it will be served (and deleted) later by the download endpoint when the operator clicks Download on PerfLocale → Jobs.
Most common use: offsite-backup hooks (copy the export to S3 / Dropbox / a remote shell) and audit-log entries. Note this action fires only from the data_export background job. An export that stays below the async threshold is streamed straight to the browser by DataExporter::download() and never touches disk, so it does not fire this hook - and neither does wp perflocale export, which calls DataExporter::write_to_file() directly.
Parameters
Parameter
Description
string$path
absolute path to the written export file (always inside uploads/perflocale/exports/).
int$bytes
size of the written file.
array<int,string>$sections
the section keys that were exported (settings, roles, languages, translations, strings, slugs, …).
Example8 lines
// Mirror every successful site export to S3.
add_action( 'perflocale/export/written', static function ( string $path, int $bytes, array $sections ): void {
if ( $bytes === 0 ) { return; }
my_addon_s3_upload(
$path,
's3://acme-backups/perflocale/' . gmdate( 'Y/m/d/' ) . basename( $path )
);
}, 10, 3 );
Fires after every gate (nonce, capability, job-status, realpath) has passed and the export file is about to be streamed to the operator's browser, BEFORE any HTTP header is sent.
Use for compliance / audit-log entries ("user X downloaded export Y at Z") and monitoring-pipeline events. Do not echo from callbacks here - output would land in the response body before the JSON download.
Fires AFTER the export file has been streamed to the browser and BEFORE the single-use deletion takes effect.
The file is still on disk at $real_path when this action fires - this is the last chance to act on it. Useful for chaining follow-up work (queue an integrity-check job that re-reads the file, kick off offsite-backup, push a webhook). Don't echo - the HTTP body is already being sent.
Parameters
Parameter
Description
string$job_id
-
string$real_path
-
array$job
-
int$size
(bytes served, 0 when filesize() failed).
Example8 lines
// Queue an integrity-check job on the freshly-served export.
add_action( 'perflocale/export/download/after_serve', static function ( string $job_id, string $path, array $job, int $size ): void {
\PerfLocale\Background\BackgroundEvents::enqueue(
'my_addon_verify_export_hash',
[ $path, hash_file( 'sha256', $path ) ],
0
);
}, 10, 4 );
Fires once for every top-level key in the import envelope that isn't a core section (settings, roles, data, etc.).
The action name is dynamic - <name> is the section key your addon used in the matching export filter. Use the same key on both sides so round-tripping works automatically.
$section_data arrives exactly as the file holds it: PerfLocale does not check, clean or re-map your section. Validate and sanitize it in your listener as you would any input your add-on saves, and treat post, term or user IDs in it as meaningful only when the file comes from this site or a copy of it (see what travels between sites). An import PerfLocale refuses stops before any section is dispatched.
Parameters
Parameter
Description
mixed$section_data
the decoded JSON payload your addon wrote to this section. Same shape the export filter returned.
array$context
{ replace: bool, format_version: int, file_path: string }. replace mirrors the import's replace-mode flag; truncate your tables when it's true. file_path is the path of the source envelope.
Example13 lines
// Acme Reviews addon: restore its own rows during an import.
add_action( 'perflocale/import/section/acme_reviews', static function ( $section_data, array $ctx ): void {
global $wpdb;
$rows = is_array( $section_data ) ? ( $section_data['rows'] ?? [] ) : [];
if ( $ctx['replace'] ) {
$wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}perflocale_addon_acme_reviews" );
}
foreach ( $rows as $r ) {
$wpdb->insert( "{$wpdb->prefix}perflocale_addon_acme_reviews", $r );
}
}, 10, 2 );
Fires after DataImporter::import() has finished restoring rows and flushing caches, with the final result stats.
Fires for BOTH sync and async (data_import job) code paths. Use for cache invalidation, audit logging, or Slack notifications.
It does not fire for an import that stops before writing anything: an unreadable or invalid file, a file that fails the data-quality checks, or a file from another site that PerfLocale refuses because the copy-confirmation box was not ticked (--force in WP-CLI). See what travels between sites. Nor does it fire when a Replace import fails and is rolled back.
When the person running the import does not have the unfiltered_html capability, PerfLocale cleans string translations, languages and translated slugs with the rules it applies when it saves the same data itself (see imports by accounts without unfiltered_html). $result carries sanitized: the number of stored rows that cleaning changed, whether markup was removed or a value was only reformatted; a row counts once, however many of its values changed. It is 0 when the importer has unfiltered_html, and always 0 for wp perflocale import and network-import, which store the text of those three sections exactly as the file has it. The admin import notice (when the number is above 0), or the job’s details for a queued import, reports the same number.
A Merge import never applies the file’s configuration. When the file carries any, $result also has not_applied, the parts Merge left unapplied (settings, addon_settings, disabled_addons, roles, each also counted in skipped), and notice, the sentence the admin import notice and the job’s details show for it. A Replace import applies them and carries neither key.
Fires after every (source post, target language) row inside the bulk-translate job, whether the translation succeeded, failed, or was short-circuited by before_translate.
Use for per-row observability: metrics, monitoring pipelines, audit events.
Rows the job passes over before the translation step reach neither this action nor before_translate: the user lacks perflocale_use_mt or cannot edit the source post, the target language is unknown, a translation already exists, or the user cannot both edit and read the default-language original the new translation would be copied from. Those rows are still counted in the job’s totals (an unknown language as failed, the others as skipped).
Parameters
Parameter
Description
int$source_id
source post ID.
string$target_slug
target language slug.
array$result
TranslationService result (['post_id' => int] on success; empty array on failure).
array$context
same shape as the before_translate context plus two extra keys:
short_circuited: bool - true if the row was handled by a before_translate filter callback.
error: string - the exception message or fallback error text; empty string on success.
Manually-triggered action: do_action( 'perflocale/addons/cache/flush' ) forces PerfLocale to invalidate its bootable-addons transient (the memoised list of which addons pass their compatibility check).
PerfLocale already flushes this automatically on plugin activation and deactivation, theme switch, and plugin/theme upgrade; call this action from a misbehaving environment where one of those lifecycle events didn’t fire.
Parameters
None
Example2 lines
// Force a rebuild of the bootable-addons cache.
do_action( 'perflocale/addons/cache/flush' );
Fires once, immediately after the registry writes an addon's declared default settings on its first successful boot (the auto-seed pass - see Developer Toolkit → Settings).
Useful for one-shot first-activation work: welcome email, sample-data import, telemetry opt-in prompt. Will NOT fire on subsequent boots even if the user clears all settings - re-firing requires an explicit AddonSettings::forget($id).
Fires inside the storage lock, immediately BEFORE the perflocale_addon_settings option commits a new entry for an addon.
Use for pre-save auditing, validation logging, or cache invalidation that needs to happen while both old and new values are still visible.
Reentrancy: the write lock is non-reentrant. Calling AddonSettings::set() / set_addon() / forget() from a listener will time out at 10 seconds and return false. For cross-addon write reactions, hook perflocale/addon/settings/after_save from a wp_schedule_single_event deferred callback so your write happens outside the lock window.
Parameters
Parameter
Description
string$addon_id
Addon being saved.
array$new_entry
The values about to commit.
array$old_entry
The pre-save values (empty array if no entry existed).
Fires inside the storage lock, immediately AFTER the option commits.
Same reentrancy caveat as before_save. This is the right hook for "addon A reacts to addon B's settings change" via a deferred dispatch:
Neither hook fires on rejected writes (invalid addon id, over the 16 KiB per-addon size cap, or lock acquisition failure). If you need to observe rejections, wrap the call site and check the bool return.
Fire-and-observe actions emitted at the end of each GC pass. Use them for monitoring (Datadog, New Relic), audit trails (“N strings GC’d on YYYY-MM-DD”), or chained cleanups. None of the callbacks block or alter the GC behaviour. See the matching tuning filters under Garbage Collection & Retention in the Filters section.
Fires after the daily mark-and-sweep GC deletes stale rows from perflocale_strings (and the cascade from perflocale_string_translations).
Only fires when at least one row was deleted - quiet ticks don’t emit. Use it for observability when shipping a wp.org plugin update, so you can confirm the GC actually fired after deploy.
Parameters
Parameter
Description
int$strings_deleted
rows deleted from perflocale_strings.
int$translations_deleted
rows deleted from perflocale_string_translations via the cascade.
Example6 lines
add_action( 'perflocale/strings/gc_complete', static function ( int $strings_deleted, int $translations_deleted ): void {
if ( function_exists( 'datadog_metric' ) ) {
datadog_metric( 'perflocale.gc.strings.deleted', $strings_deleted );
datadog_metric( 'perflocale.gc.string_translations.deleted', $translations_deleted );
}
}, 10, 2 );
Fires after the defensive orphan-sweep in the daily GC deletes perflocale_string_translations rows whose parent strings.id no longer exists.
Only fires when at least one orphan was found - a healthy install should never trigger this hook, so any callback firing is a signal that some code path bypassed the normal cascade.
Parameters
Parameter
Description
int$deleted
number of orphan rows deleted.
Example5 lines
// Alert when the orphan-sweep actually finds anything - suggests a buggy
// cascade somewhere we should investigate.
add_action( 'perflocale/string_translations/orphans_swept', static function ( int $deleted ): void {
error_log( '[PerfLocale] string_translations orphan-sweep deleted ' . $deleted . ' rows - investigate.' );
} );
Fires after PerfLocale flushes its entire cache layer (L1 static memo, every L2 object-cache group, L3 transients).
Callers: the admin “Clear cache” button, wp perflocale cache flush and wp perflocale health-check --fix, a plugin-version database migration, plugin deactivation, every importer run (data import and the WPML / Polylang / TranslatePress migrations), and the bulk “create taxonomy translations” admin action.
It does not fire when a language is added, renamed, updated or reordered - those paths run only the narrower flush_languages(), which emits no action at all. Deleting a language additionally runs flush_slugs() and flush_translations(), which emit perflocale/cache/flush_slugs and perflocale/cache/flush_translations respectively. If you purge a CDN on language changes, hook those as well as this one.
Hook this to drop your CDN’s entire zone or a wildcard tag. PerfLocale itself does not issue remote purges - that’s left to integrators (Cloudflare, Bunny, Fastly).
Parameters
None
Example3 lines
add_action( 'perflocale/cache/flush_all', function (): void {
my_cdn_purge_zone();
} );
Fires after PerfLocale flushes the translated-slug cache layer - currently invoked on language deletion (the slug_translations rows for the removed language are deleted alongside the static L1 memo, the persistent L2 group, the L3 transient envelopes, and the perflocale_has_any_slugs zero-state flag - the autoloaded option and its copy in the persistent object cache).
Hook this when your CDN or edge cache holds per-slug routing data that needs to be invalidated on the same event - e.g. a custom URL-shortener or a slug-keyed redirect table at the edge. PerfLocale does not issue remote purges itself; that’s left to integrators.
Fires when the archive hreflang cache is invalidated, after PerfLocale’s own wipe - the seam for object-cache-backed installs that need to issue an additional purge.
Fires from two paths: a post whose status / language assignment changed, and a term edit (which also flushes its translation siblings).
Parameters
Parameter
Description
int$object_id
the post ID or term ID whose save / edit / delete triggered the flush. The hook does not distinguish the two, so key any purge tag on something you can disambiguate yourself.
Example3 lines
add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $object_id ): void {
my_cdn_purge_tags( [ 'archive-hreflang:' . $object_id ] );
} );
Fires when the non-production gate blocks a dispatch - the observability hook for loggers or admin notices that surface “webhooks are intentionally muted on this clone.”
Fires once per batched DELETE during addon uninstall meta cleanup.
Parameters
Parameter
Description
string$type
-
int$deleted
-
int$total
-
Example4 lines
add_action( 'perflocale/addon/meta_purge_batch', function ( string $type, int $deleted, int $total ): void {
// $type is the OBJECT type: 'post' | 'user' | 'term' | 'comment'.
// $deleted is this batch's row count, $total the running total for $type.
}, 10, 3 );
Fires when PerfLocale is about to rebuild the files-mode translation bundles (WordPress 6.5+ .l10n.php files).
Two paths fire it, both only while string_translation_mode is files: a settings save that switches string translation to files mode, and a completed bulk string machine-translation job. Hook in to trigger downstream compilation or to piggyback on the same cache-clear window.
Fires whenever PerfLocale writes to the strings / string_translations tables, so addons that derive state from those tables can invalidate it.
Fires from three paths, each passing its origin: the admin Strings screen save (admin_save), a PO import (po_import, which in --replace mode bulk-deletes a language’s translations), and a completed bulk machine-translation job (bulk_mt).
Parameters
Parameter
Description
string$origin
what changed the strings (admin_save, po_import, or bulk_mt).
Example4 lines
add_action( 'perflocale/strings/changed', function ( string $origin ): void {
// $origin is 'admin_save', 'po_import', or 'bulk_mt'.
my_addon_invalidate_string_state( $origin );
} );
Fires when PerfLocale cannot write a generated translation file (.mo/.l10n.php) to disk - a read-only uploads directory, a full disk, a hardened host.
The strings themselves are still served from the database, so this is a performance and portability signal rather than a breakage. Hook it to surface the condition in your own monitoring.
Parameters
Parameter
Description
string$path
File that could not be written.
string$reason
Why the write failed.
Example3 lines
add_action( 'perflocale/strings/file_write_failed', function ( string $path, string $reason ): void {
error_log( "PerfLocale could not write {$path}: {$reason}" );
}, 10, 2 );
Beyond PHP, PerfLocale exposes one stable JavaScript filter via the WordPress @wordpress/hooks registry. Use it from a custom block plugin to teach the PerfLocale block-toolbar Translate action which attributes hold translatable text on your block type.
perflocale.blockToolbar.textAttrs (filter)
Filter the ordered list of attribute names the block toolbar will look at when extracting text from a block for machine translation. The default chain inspects the block’s registered attribute schema and returns names of string / rich-text attributes that look translatable (skipping className, anchor, alignment etc.). Return an extended array to opt your custom block in - cleanest extension surface that doesn’t require a code change in PerfLocale.
wp.hooks.addFilter(
'perflocale.blockToolbar.textAttrs',
'acme/my-block',
function ( chain, blockName ) {
if ( blockName === 'acme/quote-card' ) {
// Try `quoteHtml` first, then `attribution`, then fall back to defaults.
return [ 'quoteHtml', 'attribution' ].concat( chain );
}
return chain;
}
);
Parameters:
string[] chain - Ordered list of attribute names. Translate to and Fill in from source translate every attribute in the chain that holds text and write each back to its own attribute. Fill in from source sends the chain to the server, so the original block is read through the same attributes.
string blockName - The block being inspected (e.g. core/paragraph, acme/quote-card).
Return: array. An empty / non-array return value is ignored and the toolbar falls back to [ 'content' ]. File:assets/js/block-toolbar.js