Hooks Reference
Developer reference for all apply_filters and do_action hooks in PerfLocale.
Filters
Settings & Configuration
perflocale/translatable_post_types
Filter the list of translatable post types.
add_filter( 'perflocale/translatable_post_types', function ( array $post_types ): array {
$post_types[] = 'product'; // Add WooCommerce products.
return $post_types;
} );Parameters: array $post_types - Array of post type slugs.
File: src/Settings.php
perflocale/translatable_taxonomies
Filter the list of translatable taxonomies.
add_filter( 'perflocale/translatable_taxonomies', function ( array $taxonomies ): array {
$taxonomies[] = 'product_cat';
return $taxonomies;
} );Parameters: array $taxonomies - Array of taxonomy slugs.
File: src/Settings.php
perflocale/excluded_paths
Filter URL paths excluded from language prefix injection.
add_filter( 'perflocale/excluded_paths', function ( array $paths ): array {
$paths[] = '/my-api/'; // Custom REST endpoint.
$paths[] = '/sitemap.xml'; // Sitemap file.
return $paths;
} );Parameters: array $paths - Array of path prefixes (e.g., '/wp-json/').
File: src/Settings.php
perflocale/cookie_lifetime
Filter the language cookie lifetime in days.
add_filter( 'perflocale/cookie_lifetime', function ( int $days ): int {
return 30; // 30 days instead of default 365.
} );Parameters: int $days - Cookie lifetime in days.
File: src/Router/LanguageRouter.php
perflocale/strings/protected_domains
Text domains whose strings the daily stale-string garbage collector must never delete. Backed by the persistent perflocale_gc_protected_domains option, so an addon's protection survives its own deactivation.
add_filter( 'perflocale/strings/protected_domains', function ( array $domains ): array {
$domains[] = 'my-plugin'; // Never GC my plugin's registered strings.
return $domains;
} );Parameters: string[] $domains - Protected text domains.
File: src/Database/Repository/StringRepository.php
perflocale/strings/po_export_batch_size
Rows fetched per batch while streaming a PO export. Higher = fewer queries, more peak memory per batch. Clamped to 100–10,000.
add_filter( 'perflocale/strings/po_export_batch_size', function ( int $batch_size ): int {
return 5000; // Bigger batches on a memory-rich host.
} );Parameters: int $batch_size - Rows per batch (default 1000).
File: src/Admin/PoSync.php
perflocale/site_health/string_blob_threshold
Rows-per-language at which the Site Health check warns that database string mode is loading a large per-request blob. Default 20,000.
add_filter( 'perflocale/site_health/string_blob_threshold', function ( int $threshold ): int {
return 40000; // Warn later on a memory-rich host.
} );Parameters: int $threshold - Rows per language (default 20000).
File: src/Admin/SiteHealth.php
perflocale/site_health/probe_export_exposure
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.
add_filter( 'perflocale/site_health/probe_export_exposure', '__return_false' );Parameters: bool $probe - Whether to write the canary and make the request (default true).
File: src/Admin/SiteHealth.php
perflocale/switcher/add_to_menu
Whether to append the language switcher to a classic menu. Defaults to true when any menu location is selected on the Language Switcher settings tab; a filter can force it per menu.
add_filter( 'perflocale/switcher/add_to_menu', function ( bool $add, $args ): bool {
return $args->theme_location === 'primary'; // Only the primary menu.
}, 10, 2 );Parameters: bool $add, stdClass $args (wp_nav_menu args).
File: src/Frontend/LanguageSwitcher.php
perflocale/active_languages
Filter the list of active languages returned by the router.
// Hide a language on specific pages.
add_filter( 'perflocale/active_languages', function ( array $languages ): array {
if ( is_page( 'internal-only' ) ) {
return array_filter( $languages, fn( $l ) => $l->slug !== 'de' );
}
return $languages;
} );Parameters: array $languages - Array of language objects.
File: src/Router/LanguageRouter.php
perflocale/date_format
Filter the date format that perflocale()->date_format() resolves for a given language. 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.
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 );Parameters: string $format, object|null $lang.
File: src/Helper.php
perflocale/time_format
Same shape as perflocale/date_format, applied to the time format resolution path (perflocale()->time_format()).
Parameters: string $format, object|null $lang.
File: src/Helper.php
Translation Creation
perflocale/translation/post_data
Filter the new post data before a translation is created via wp_insert_post().
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 );Parameters:
array $data- Post data array (post_type,post_status,post_title, etc.).int $source_id- Source post ID.string $target_slug- Target language slug.
File: src/Translation/PostTranslationManager.php
perflocale/translation/excluded_meta_keys
Filter the meta keys excluded from being copied when creating a translation.
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 );Parameters:
array $keys- Meta key names to exclude.int $source_id- Source post ID.
File: src/Translation/PostTranslationManager.php
perflocale/translation/create_lock_ttl
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.
// 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 );Parameters: int $ttl - Lock TTL in seconds. Default 60. Floor 5.
File: src/Translation/PostTranslationManager.php
perflocale/translation/create_term_lock_ttl
Same as perflocale/translation/create_lock_ttl but for taxonomy term translations. Default is shorter (30s) because terms have no meta/image copy step.
add_filter( 'perflocale/translation/create_term_lock_ttl', static fn(): int => 60 );Parameters: int $ttl - Lock TTL in seconds. Default 30. Floor 5.
File: src/Translation/TermTranslationManager.php
Content Sync
perflocale/sync_fields
Filter the fields synchronized across translations when a post is saved.
// Add custom meta keys to sync programmatically.
add_filter( 'perflocale/sync_fields', function ( array $fields, string $post_type ): array {
if ( $post_type === 'product' ) {
$fields[] = '_price';
$fields[] = '_sku';
$fields[] = '_stock_status';
}
return $fields;
}, 10, 2 );Parameters:
array $fields- Field names (built-in keys likefeatured_image,menu_order, or custom meta keys).string $post_type- The post type being saved.
Built-in field keys: featured_image, menu_order, post_parent, post_date, post_author, comment_status, ping_status. Since 1.0.1 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. 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.
File: src/Translation/ContentSync.php
perflocale/sync_fields/for_post
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.
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 );Parameters: array $sync_fields - meta keys to sync; int $post_id - source post id.
Args: 2
File: src/Translation/ContentSync.php
perflocale/sync/term_parent
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.
// 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 );Parameters:
bool $sync—trueby 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.
Args: 5
File: src/Translation/ContentSync.php
perflocale/translatable_meta_keys
Add meta keys to the auto-translated set without requiring the user to enter each one in Settings → Translation. Bundled addons (Yoast, ACF, page builders) use this hook to register their own meta keys; your custom plugin can do the same.
add_filter( 'perflocale/translatable_meta_keys', function ( array $keys, string $post_type ): array {
if ( $post_type === 'job_listing' ) {
$keys[] = '_job_description';
$keys[] = '_job_location';
}
return $keys;
}, 10, 2 );Parameters: array $keys, string $post_type (may be empty for "any post type").
File: src/Settings.php
perflocale/media/show_featured_image_panel
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.
// 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 );Parameters:
bool $show—trueby default.string $post_type— the post type being evaluated.
Returns: bool.
File: src/Translation/MediaTranslationManager.php
perflocale/media/featured_image_panel_languages
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.
// 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 );Parameters:
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.
File: src/Translation/MediaTranslationManager.php
URL Handling
perflocale/router/bot_ua_pattern
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.
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';
} );Parameters: string $pattern - Compiled regex (with delimiters and flags).
File: src/Router/LanguageRouter.php
perflocale/url/convert
Filter a URL after it has been converted to a target language.
add_filter( 'perflocale/url/convert', function ( string $url, string $target_slug, string $current_slug ): string {
// Custom URL transformation.
return $url;
}, 10, 3 );Parameters:
string $url- The converted URL.string $target_slug- Target language slug.string $current_slug- Current language slug.
File: src/Router/UrlConverter.php
perflocale/redirect_default_to_prefix
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.
add_filter( 'perflocale/redirect_default_to_prefix', '__return_false' );Parameters: bool $enabled - default true.
File: src/Router/LanguageRouter.php
perflocale/redirect_prefix_to_default
The mirror of perflocale/redirect_default_to_prefix. 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.
add_filter( 'perflocale/redirect_prefix_to_default', '__return_false' );Parameters: bool $enabled - default true.
File: src/Router/LanguageRouter.php
perflocale/url_mode
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.
// Force query-parameter mode on a staging box that can't rewrite.
add_filter( 'perflocale/url_mode', fn() => 'query' );Parameters: string $mode - one of subdirectory, subdomain, domain, query.
File: src/Settings.php
perflocale/url/query_var
Rename the query variable used by query URL mode. 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.
// wp-content/mu-plugins/my-lang-var.php
// Leave `lang` to another plugin.
add_filter( 'perflocale/url/query_var', fn() => 'pfl_lang' );Parameters: string $query_var - default lang.
File: src/Router/UrlConverter.php
SEO
perflocale/seo/hreflang_tags
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.
// Remove the x-default tag entirely.
add_filter( 'perflocale/seo/hreflang_tags', function ( array $tags ): array {
return array_filter( $tags, fn( $t ) => $t['hreflang'] !== 'x-default' );
} );
// Add a regional alternate that PerfLocale doesn't manage itself.
add_filter( 'perflocale/seo/hreflang_tags', function ( array $tags ): array {
$tags[] = [ 'hreflang' => 'en-ca', 'href' => 'https://ca.example.com/' ];
return $tags;
} );Parameters: array $tags - array of ['hreflang' => string, 'href' => string], computed per-request and cached.
File: src/Frontend/HreflangTags.php
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.
perflocale/seo/hreflang_cache_ttl
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.
// Shorten archive hreflang caching to one hour.
add_filter( 'perflocale/seo/hreflang_cache_ttl', fn() => HOUR_IN_SECONDS );Parameters: int $ttl - cache lifetime in seconds (default 12 * HOUR_IN_SECONDS).
File: src/Frontend/HreflangTags.php
perflocale/seo/x_default_url
Filter just the URL used for the x-default entry. 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.
// 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;
} );Parameters:
string $default_url- URL currently slated for x-default (PerfLocale’s computed value).object $default- default-language object (slug, locale, name, etc.).string $current_slug- slug of the language currently being rendered.
File: src/Frontend/HreflangTags.php
perflocale/seo/hreflang_include_fallbacks
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.
// 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;
} );Parameters: bool $enabled - current setting value (defaults to false).
Since: 1.0.0
File: src/Frontend/HreflangTags.php
perflocale/sitemap/inject_into_core
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.
// 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' );Parameters: bool $decision - the pre-filter decision derived from the seo_sitemap_source setting.
File: src/Seo/SitemapIntegration.php
perflocale/sitemap/max_prime
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.
Parameters: int $max - Maximum objects to prime.
File: src/Seo/SitemapIntegration.php
Modern SEO & UX (1.0.0)
perflocale/content_language/value
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.
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 );Parameters: string $locale, object $current_lang - the language object from the router.
File: src/Frontend/ContentLanguageHeader.php
perflocale/view_transitions/css
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.
// 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; }';
} );Parameters: string $css - default CSS block (includes @view-transition, the 240ms crossfade rules, and the prefers-reduced-motion guard).
File: src/Frontend/ViewTransitionsEmitter.php
perflocale/view_transitions/should_emit
Short-circuit the View Transitions emitter on specific templates. Useful when a theme runs its own GSAP/slider animations on navigation that would fight the browser transition.
add_filter( 'perflocale/view_transitions/should_emit', function ( bool $should ): bool {
if ( is_singular( 'portfolio' ) ) {
return false; // GSAP page transitions are active on portfolio items.
}
return $should;
} );Parameters: bool $should_emit - defaults to true when the setting is on.
File: src/Frontend/ViewTransitionsEmitter.php
perflocale/prerender/rules
Fallback path only (WP 6.4–6.7). Filter the Speculation Rules JSON payload before emission. On WP 6.8+ use Core’s wp_load_speculation_rules action instead; our rule is registered via that API and is no longer filterable through this hook.
add_filter( 'perflocale/prerender/rules', function ( array $rules, array $urls ): array {
// Switch to prefetch instead of prerender on this site.
if ( isset( $rules['prerender'] ) ) {
$rules = [ 'prefetch' => $rules['prerender'] ];
}
return $rules;
}, 10, 2 );Parameters: array $rules, array $urls.
File: src/Frontend/SpeculationRulesEmitter.php
perflocale/prerender/should_emit
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.
add_filter( 'perflocale/prerender/should_emit', function ( bool $should ): bool {
if ( is_page_template( 'templates/heavy-media-gallery.php' ) ) {
return false;
}
return $should;
} );Parameters: bool $should_emit.
File: src/Frontend/SpeculationRulesEmitter.php
perflocale/prerender/use_core_api
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.
add_filter( 'perflocale/prerender/use_core_api', '__return_false' );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: bool $use_core - defaults to true when \WP_Speculation_Rules exists.
File: src/Frontend/SpeculationRulesEmitter.php
perflocale/fallback/wrap_nosnippet
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.
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 );Parameters: bool $wrap (default true), int $post_id.
File: src/Translation/FallbackSnippetGuard.php
perflocale/fallback/nosnippet_tag
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.
add_filter( 'perflocale/fallback/nosnippet_tag', fn( $tag ) => 'span' );Parameters: string $tag, int $post_id.
File: src/Translation/FallbackSnippetGuard.php
Core filter - applied by PerfLocale on both paths
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.
// Exclude action URLs from speculative prerendering.
add_filter( 'wp_speculation_rules_href_exclude_paths', function ( array $paths, string $mode ): array {
if ( $mode === 'prerender' ) {
$paths[] = '/add-to-cart/*';
$paths[] = '/wishlist-toggle/*';
}
return $paths;
}, 10, 2 );Parameters: array $paths, string $mode (prefetch or prerender — PerfLocale always passes prerender).
File: WordPress Core 6.8+ | src/Frontend/SpeculationRulesEmitter.php — apply_exclude_paths_filter(), called from both add_core_rule() (WP 6.8+) and emit_fallback() (WP 6.4–6.7).
Language Switcher
perflocale/switcher/languages
Filter the languages available in the switcher.
Parameters: array $languages - Array of language objects.
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/output
Filter the final HTML output of the language switcher.
Parameters:
string $html- Rendered HTML.array $args- Switcher arguments.
File: src/Frontend/LanguageSwitcher.php
Dev-mode guard: a non-string return triggers _doing_it_wrong() and the renderer falls back to the unfiltered HTML.
perflocale/switcher/link_attrs
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.
// Add GA4 + Hotjar data attributes to every switcher link.
add_filter( 'perflocale/switcher/link_attrs', function ( array $attrs, object $lang, string $current_slug, array $args ): array {
$attrs['data-lang'] = $lang->slug;
$attrs['data-gtm-event'] = 'language_switch';
$attrs['data-hj-track'] = 'lang-switcher';
return $attrs;
}, 10, 4 );
// Add rel="alternate" for better SEO signal + hreflang override.
add_filter( 'perflocale/switcher/link_attrs', function ( array $attrs, object $lang ): array {
$attrs['rel'] = 'alternate';
$attrs['hreflang'] = $lang->locale ? str_replace( '_', '-', strtolower( $lang->locale ) ) : $lang->slug;
return $attrs;
}, 10, 2 );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-rtlwhen the target language'stext_directionis 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.
Parameters:
array $attrs- Attribute name => value pairs.object $lang- Language object for this link (slug,name,native_name,locale,flag,text_direction).string $current_slug- Currently active language slug.array $args- Switcher arguments (template / display / layout / className …).
Args: 4
File: src/Frontend/LanguageSwitcher.php
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.
perflocale/switcher/arrow_html
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.
// 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 );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:
string $html- Computed arrow HTML for$style(empty when$styleisnoneor unknown).string $style- Resolved style key (single,double,none, or any custom keyword a theme uses as a sentinel).
Args: 2
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/panel_before
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.
add_filter( 'perflocale/switcher/panel_before', function ( string $html, array $languages, string $current_slug, array $attrs ): string {
return '<div role="presentation" class="my-switcher-heading">' . esc_html__( 'Choose your language', 'my-theme' ) . '</div>';
}, 10, 4 );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:
string $html- HTML to inject (default empty).array $languages- Languages about to be rendered (objects withslug,name,native_name,locale,flag,text_direction).string $current_slug- Currently active language slug.array $attributes- Resolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).
Args: 4
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/panel_after
Inject HTML at the END of the dropdown panel, inside the role="listbox" container, after the last option. Same sanitisation, parameters, and role guidance as panel_before above.
// Add a "Manage languages" link visible only to admins.
add_filter( 'perflocale/switcher/panel_after', function ( string $html ): string {
if ( ! current_user_can( 'manage_options' ) ) { return $html; }
return '<a role="presentation" href="' . esc_url( admin_url( 'admin.php?page=perflocale-languages' ) ) . '">' . esc_html__( 'Manage languages', 'my-theme' ) . '</a>';
} );Parameters: Same as panel_before.
Args: 4
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/option_content
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.
// Append a region badge to every option label.
add_filter( 'perflocale/switcher/option_content', function ( string $inner, object $lang ): string {
$region = $lang->slug === 'pt-br' ? 'Brasil'
: ( $lang->slug === 'pt' ? 'Portugal' : '' );
if ( $region === '' ) { return $inner; }
return $inner . ' <span class="lang-region">· ' . esc_html( $region ) . '</span>';
}, 10, 2 );
// Wrap the label in <bdi> for safe mixed-direction rendering.
add_filter( 'perflocale/switcher/option_content', function ( string $inner ): string {
return '<bdi>' . $inner . '</bdi>';
} );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:
string $inner- Default inner HTML (flag<span>+ label<span>, either of which may be omitted depending on theshowFlags/showNames/stylesettings).object $lang- Language object for this option (slug,name,native_name,locale,flag,text_direction).string $current_slug- Currently active language slug.array $attrs- Resolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).
Args: 4
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/kses_allowed_html
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).
// 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;
} );The map follows the standard wp_kses_allowed_html shape: [ 'tag' => [ 'attr' => true, … ], … ].
Parameters:
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.
Args: 2
File: src/Frontend/LanguageSwitcherBlock.php
Conditional Content
perflocale/conditional_content/allowed_html
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.
add_filter( 'perflocale/conditional_content/allowed_html', function ( array $allowed, string $content ): array {
$allowed['custom-element'] = [ 'class' => true, 'data-config' => true ];
return $allowed;
}, 10, 2 );Parameters:
array $allowed- Allowed HTML map, standardwp_kses_allowed_htmlshape.string $content- The rendered content being escaped, for context-aware decisions.
Args: 2
File: src/Frontend/ConditionalContent.php
String Translation
perflocale/string/translate
Filter a translated string before it is returned.
Parameters:
string $translated- The translated string.string $text- The original string.string $domain- Text domain.string $lang_slug- Current language slug.
File: src/Strings/StringTranslation.php
perflocale/strings/scanner/excluded_paths
Filter directory patterns excluded from string scanning.
Parameters: array $excluded - Array of directory patterns.
File: src/Strings/StringScanner.php
Dev-mode guard: a non-array return triggers _doing_it_wrong() and the scanner falls back to the built-in excluded-paths list.
perflocale/string/needs_update
Fires when a registered string's source text changes and its existing translations are marked as needing review. Useful for triggering automated re-translation, sending notifications, or logging changes.
add_action( 'perflocale/string/needs_update', function ( int $string_id, string $old_text, string $new_text, string $domain, string $context ): void {
// Example: log the change or trigger auto-translation.
error_log( "String #{$string_id} changed in {$domain}/{$context}" );
}, 10, 5 );Parameters:
int $string_id- The new string's ID.string $old_text- The previous source text that was replaced.string $new_text- The new source text.string $domain- Text domain (e.g.perflocale,woocommerce).string $context- Translation context (e.g.email_subject_new_order, or an empty string when the string was registered without one).
Args: 5
File: src/Database/Repository/StringRepository.php
Machine Translation
perflocale/machine_translation/providers
Filter the registered machine translation providers.
Parameters: array $providers - Array of provider objects.
File: src/MachineTranslation/TranslationService.php
perflocale/mt/pre_translate
Filter texts before they are sent to a machine translation provider.
Parameters:
array $texts- Array of text strings.string $source_lang- Source language code.string $target_lang- Target language code.string $provider_id- Provider identifier.
File: src/MachineTranslation/TranslationService.php
perflocale/mt/post_translate
Filter translated texts after they are returned from a machine translation provider.
Parameters:
array $translated- Translated text strings.array $originals- Original text strings.string $target_lang- Target language code.string $provider_id- Provider identifier.
File: src/MachineTranslation/TranslationService.php
Dev-mode guard: returns that are not array<int,string> trigger _doing_it_wrong() and the provider response is used unfiltered.
perflocale/mt/pre_translate_lookup
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.
// Answer from a house translation memory before spending provider quota.
add_filter( 'perflocale/mt/pre_translate_lookup', function ( $translation, string $text, string $source_lang, string $target_lang ) {
if ( is_string( $translation ) && $translation !== '' ) {
return $translation; // Another integration already answered.
}
$hit = get_transient( 'acme_tm_' . md5( $source_lang . '|' . $target_lang . '|' . $text ) );
return is_string( $hit ) && $hit !== '' ? $hit : null;
}, 10, 4 );Parameters:
string|null $translation—nullby 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.
Args: 4
File: src/Api/BlockTranslateController.php
perflocale/mt/should_auto_translate
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.
// 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 );Parameters:
bool $should— the computed decision (truewhen 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, or0when none exists.
Returns: bool — false skips this language for this run.
Args: 4
File: src/Bootstrap.php
perflocale/machine_translation/text_before_send
Filter individual text before sending to a provider.
Parameters:
string $text- Text to translate.string $provider_id- Provider identifier (e.g.,'deepl','google').string $target_lang- Target language code.
File: src/MachineTranslation/Provider/*.php
perflocale/machine_translation/result
Filter individual translation result from a provider.
Parameters:
string $translated- Translated text.string $original- Original text.string $provider_id- Provider identifier.
File: src/MachineTranslation/Provider/*.php
perflocale/mt/request_args
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.
// 90-second timeout + corporate proxy + custom UA for the DeepL endpoint.
add_filter( 'perflocale/mt/request_args', function ( array $args, string $url, string $provider_id ): array {
if ( $provider_id !== 'deepl' ) {
return $args;
}
$args['timeout'] = 90;
$args['user-agent'] = 'AcmeCorp-Translation/1.0';
$args['headers']['X-Forwarded-Via'] = 'corp-gw';
return $args;
}, 10, 3 );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.
Parameters:
array $args-wp_remote_request()argument array (timeout, headers, body, method, sslverify …).string $url- Destination URL (already SSRF-validated).string $provider_id- Provider slug (deepl,google,microsoft,libretranslate,external_agency,wp_ai_client).
Args: 3
File: src/MachineTranslation/AbstractProvider.php
perflocale/mt/trusted_hosts
Filter the list of hostnames that skip the slow gethostbyname() step in PerfLocale's SSRF validator. Use it to register custom MT-provider hosts (for example a custom AI translator addon calling api.cohere.ai or your own self-hosted gateway) so their outbound requests don't hit the no-timeout DNS path.
// Allow a self-hosted AI gateway and Cohere's translate endpoint to skip
// the DNS step. Both must already resolve to public IPs - a host listed
// here skips every remaining SSRF gate, so only add names you control.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
$hosts[] = 'api.cohere.ai';
$hosts[] = 'translate.acmecorp.internal-public.example';
return $hosts;
} );Important: this filter is a performance fast-path, and it is a genuine bypass of every check that follows it — treat it as a security-relevant setting. A host you add here returns from validate_url() immediately, so the IPv4 private/reserved-range gate, the IPv6 unique-local (fc00::/7) and link-local (fe80::/10) gates, the DNS resolution, and the resolved-IP private/loopback verification are all skipped for it. 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. Never add an IP literal, and only add hostnames you control and have verified resolve to public IPs. 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 the five bundled MT-provider endpoints: translation.googleapis.com, api.deepl.com, api-free.deepl.com, api.cognitive.microsofttranslator.com, libretranslate.com. Anything else — a self-hosted LibreTranslate, a custom agency endpoint, an AI gateway — goes through the DNS step unless you add it here.
Parameters:
array<int, string> $hosts- Lowercase trusted hostnames. Add to or replace this array.
Args: 1
File: src/MachineTranslation/AbstractProvider.php
perflocale/mt/retry_after_max_seconds
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.
// Never sleep more than a minute on a rate-limit backoff.
add_filter( 'perflocale/mt/retry_after_max_seconds', fn(): int => 60 );Parameters: int $max_seconds - default 300.
File: src/MachineTranslation/AbstractProvider.php
perflocale/mt/pre_translate_text
Filter the source text right before it is sent to the MT provider. Lets you mask placeholders, strip shortcodes, or pre-substitute branded terms.
add_filter( 'perflocale/mt/pre_translate_text', function ( string $text, string $source_lang, string $target_lang, string $provider_id ): string {
// Strip our internal {ATTR:foo} placeholders so the MT provider doesn't see them.
return preg_replace( '/\{ATTR:[a-z_]+\}/', '', $text );
}, 10, 4 );Parameters: string $text, string $source_lang, string $target_lang, string $provider_id.
File: src/MachineTranslation/TranslationService.php
perflocale/mt/post_translate_text
Filter the translated text after the MT provider returns it, and after the kses sanitisation pass. The fifth argument is the provider id so a single callback can branch by provider.
add_filter( 'perflocale/mt/post_translate_text', function ( string $translated, string $source, string $source_lang, string $target_lang, string $provider_id ): string {
// DeepL sometimes leaves a stray space before German punctuation; tidy it.
if ( $provider_id === 'deepl' && $target_lang === 'de' ) {
$translated = preg_replace( '/\s+([,.;:!?])/', '$1', $translated );
}
return $translated;
}, 10, 5 );Parameters: string $translated, string $source, string $source_lang, string $target_lang, string $provider_id.
File: src/MachineTranslation/TranslationService.php
perflocale/mt/agency_async_response
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 causes the provider to throw RuntimeException rather than silently fall back to source text (the pre-1.x behaviour, which corrupted translation posts).
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.
// 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 );When this fires: the agency returned a 2xx response but the decoded body has no translation string key. The previous behaviour was to silently return the source text and bill monthly characters; the current behaviour throws unless this filter supplies a string. See Machine Translation → External Translation Agency.
Parameters:
string|null $translation— Defaultnull(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.array $context— Provider context:source_lang,target_lang,provider_id(alwaysexternal_agency).
Args: 5
File: src/MachineTranslation/Provider/ExternalAgencyProvider.php
perflocale/mt/translatable_meta_keys
The machine-translatable meta-key registry. 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.
add_filter( 'perflocale/mt/translatable_meta_keys', function ( array $keys, string $post_type, int $post_id ): array {
if ( $post_type === 'product' ) {
$keys[] = '_my_product_tagline'; // A plain-text field.
}
return $keys;
}, 10, 3 );Parameters: array $keys - Meta keys to machine-translate.string $post_type - Post type being translated.int $post_id - Source post ID (0 for type-level cost estimates).
File: src/MachineTranslation/MetaTranslator.php
perflocale/mt/meta_key_format
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', matching the historical behaviour. 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.
add_filter( 'perflocale/mt/meta_key_format', function ( string $format, string $key, string $post_type ): string {
if ( $key === '_my_seo_title' ) {
return 'text'; // Plain text - don't entity-escape.
}
return $format;
}, 10, 3 );Parameters: string $format - 'html' (default) or 'text'.string $key - Meta key.string $post_type - Source post type.
File: src/MachineTranslation/MetaTranslator.php
perflocale/mt/string_translated
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.
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 );Parameters:
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.
File: src/Background/Jobs/BulkStringTranslateJob.php
perflocale/sync/mirror_meta_keys
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. Page-builder layout keys (Elementor, Bricks, Oxygen, Beaver Builder) use this. Keys not in this set get seed-only semantics — copied once when the sibling has no value, never overwritten. Mirror keys are also excluded from machine translation.
add_filter( 'perflocale/sync/mirror_meta_keys', function ( array $keys, string $post_type ): array {
$keys[] = '_my_builder_layout'; // Source-owned layout blob.
return $keys;
}, 10, 2 );Parameters: array $keys - Meta keys to mirror (defaults to the configured sync fields).string $post_type - Post type being synced.
File: src/Translation/ContentSync.php
perflocale/sync/after_mirror
Fires after full-mirror meta keys have been written to a sibling translation. Page-builder addons hook this to invalidate the sibling’s GENERATED CSS/asset caches (for example Elementor’s _elementor_css / _elementor_page_assets meta) that a raw meta mirror can’t reach on its own — without it the translated page keeps enqueueing a stylesheet built from the old layout until a manual editor save or a site-wide regenerate. Only fires when at least one mirror key was actually written. PerfLocale’s bundled Elementor, Beaver Builder, and Oxygen addons consume it.
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 the mirrored layout.
delete_post_meta( $target_id, '_elementor_css' );
}, 10, 3 );Parameters: 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.
File: src/Translation/ContentSync.php
WordPress 6.9+ / 7.0+ Integrations (feature-detected — inert on earlier WordPress versions)
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.
perflocale/mt/wp_ai_client_resolver
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.
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 );
};
} );Parameters: null|callable $resolver - Callable accepting (string $prompt, array $args). Return null to fall through to auto-detection.
File: src/MachineTranslation/Provider/WpAiClientProvider.php
perflocale/mt/wp_ai_client_prompt
Customise the structured prompt sent to the AI Client per translation request. Keep the rules about placeholder preservation (the integrity gate enforces them) and the "output only the translation" instruction.
add_filter( 'perflocale/mt/wp_ai_client_prompt', function ( string $prompt, string $text, string $source, string $target ): string {
// Inject brand-voice guidance.
return $prompt . "\n\nAlways translate as if writing for a friendly, conversational SaaS audience.";
}, 10, 4 );Parameters: string $prompt, string $text, string $source_lang, string $target_lang.
File: src/MachineTranslation/Provider/WpAiClientProvider.php
perflocale/mt/wp_ai_client_args
Filter the argument array passed to the AI Client call (capability, temperature, timeout, custom provider/model selectors).
add_filter( 'perflocale/mt/wp_ai_client_args', function ( array $args, bool $fast_fail ): array {
$args['provider'] = 'anthropic';
$args['model'] = 'claude-opus-4-7';
return $args;
}, 10, 2 );Parameters: array $args, bool $fast_fail.
File: src/MachineTranslation/Provider/WpAiClientProvider.php
perflocale/mt/wp_ai_client_capability
Filter the capability tag the AI Client should match. Defaults to 'text-generation'. Set to 'translation' or another capability tag if your AI gateway routes calls by capability.
add_filter( 'perflocale/mt/wp_ai_client_capability', fn() => 'translation' );Parameters: string $capability.
File: src/MachineTranslation/Provider/WpAiClientProvider.php
perflocale/switcher/auto_insert_anchor
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.
// 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' ] );Parameters: array|false $override, string $anchor_block_type, string $relative_position.
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/switcher/auto_insert_attrs
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.
add_filter( 'perflocale/switcher/auto_insert_attrs', function ( array $attrs, string $anchor, string $position ): array {
$attrs['style'] = 'flags_only';
$attrs['flagSize'] = 14;
return $attrs;
}, 10, 3 );Parameters: array $attrs, string $anchor_block_type, string $relative_position, array|null $parsed_anchor_block.
File: src/Frontend/LanguageSwitcherBlock.php
perflocale/abilities/js_screens
Extend the list of admin screens where the JS Abilities shim is enqueued. By default the shim ships on post.php, post-new.php, and site-editor.php — sites running a custom block-editor surface can opt that hook in here.
add_filter( 'perflocale/abilities/js_screens', function ( array $hooks ): array {
$hooks[] = 'toplevel_page_my-custom-editor';
return $hooks;
} );Parameters: string[] $screens.
File: src/Admin/Assets.php
perflocale/abilities/js_payload
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, ... }, ... ] ].
add_filter( 'perflocale/abilities/js_payload', function ( array $payload ): array {
$payload['abilities'] = array_filter(
$payload['abilities'] ?? [],
fn( array $a ): bool => $a['name'] !== 'perflocale/translate-post'
);
return $payload;
} );Parameters: array $payload.
File: src/Admin/Assets.php
perflocale/abilities/convert_url_permission
Filter the permission gate for the perflocale/convert-url ability (its REST/MCP endpoint). Default requires a logged-in user. Only consulted when the Abilities API integration is enabled.
// Allow anonymous URL conversion through the ability.
add_filter( 'perflocale/abilities/convert_url_permission', '__return_true' );Parameters: bool $allowed - default is_user_logged_in().
File: src/AbilitiesRegistrar.php
perflocale/connectors/resolver
Override the callable used to look up keys in the WordPress Connectors API. Useful for tests, custom in-house key vaults, or as a forward-compatibility shim if core renames the underlying function.
add_filter( 'perflocale/connectors/resolver', function () {
return function ( string $slug ): ?string {
// Pull from your own secrets store.
return MyVault::get_key( $slug );
};
} );Parameters: null|callable $resolver.
File: src/Settings.php
perflocale/connectors/slug_map
Extend the setting-key → connector-slug map to route additional API keys through the Connectors API (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 the Connectors API broker it too. Unknown connector slugs simply return null from the resolver and fall through to env / constant / DB as usual.
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;
} );Parameters: array<string, string> $map - perflocale_setting_key => connector_slug.
File: src/Settings.php
Query Filtering
perflocale/query/include_all_languages
Override language filtering to include all languages in a query.
add_filter( 'perflocale/query/include_all_languages', function ( bool $include, WP_Query $query ): bool {
if ( $query->get( 'post_type' ) === 'faq' ) {
return true; // Show FAQs in all languages.
}
return $include;
}, 10, 2 );Parameters:
bool $include- Whether to include all languages (defaultfalse).WP_Query $query- The query object.
File: src/Translation/PostQueryFilter.php
perflocale/query/optimize_found_rows
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 has not already opted out of counting and that carries PerfLocale’s language JOIN — all / empty fields, a non-zero perflocale_language_id, on a site that has translation groups.
add_filter( 'perflocale/query/optimize_found_rows', function ( bool $enabled, \WP_Query $query ): bool {
// Keep core counting for one custom archive.
return $query->get( 'post_type' ) === 'event' ? false : $enabled;
}, 10, 2 );Parameters: bool $enabled (default true), \WP_Query $query.
Args: 2
File: src/Translation/PostQueryFilter.php
Addons
perflocale/addons/registered
Filter the list of registered addons.
Parameters: array $addons - Array of addon definitions.
File: src/Addon/AddonRegistry.php
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.
perflocale/addon/delete_data_on_uninstall
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.
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 );Parameters: bool $delete, string $addon_id, PerfLocale\Addon\PurgePlan $plan.
File: src/Addon/AddonUninstaller.php
perflocale/addon/is_compatible
Filter whether an addon is compatible with the current environment.
Parameters:
bool $compatible- Compatibility result.string $addon_id- Addon identifier.
File: src/Addon/AddonRegistry.php
perflocale/addons/quarantine_threshold
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.
// 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 );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: int $threshold — Default 3. Min effective value 1; values ≤ 0 disable quarantine.
File: src/Addon/AddonRegistry.php
perflocale/addon/manifest/check_soft_prefix_namespacing
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).
// 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' );Parameters:
bool $check— Defaulttrue. Returnfalseto skip the check for this addon.string $addon_id— Identifier of the addon whose manifest is being written.
Args: 2
File: src/Addon/AddonManifestWriter.php
WooCommerce
perflocale/woocommerce/exchange_rate_providers
Register exchange-rate providers. 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.
Parameters: array $providers - Provider definitions (see Exchange Rates docs).
File: src/WooCommerce/ExchangeRateSync.php
perflocale/woocommerce/exchange_rates_fetched
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:
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).
File: src/WooCommerce/ExchangeRateSync.php
perflocale/woocommerce/inventory_sync_enabled
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.
// 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;
} );Parameters: bool $enabled - The wc_sync_stock setting (default true).
Args: 1
File: src/WooCommerce/InventorySync.php
perflocale/woocommerce/synced_product_fields
Control which product meta keys are synced across language variants.
Parameters:
array $fields- Meta key list.int $product_id- Product being synced.
File: src/WooCommerce/InventorySync.php
perflocale/woocommerce/synced_variation_fields
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.
// 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 );Parameters:
array $fields- Variation meta keys to sync.int $variation_id- The variation being saved.
Args: 2
File: src/WooCommerce/InventorySync.php
perflocale/woocommerce/sync_on_import
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.
// 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 );Parameters:
bool $sync-trueby default.WC_Product $product- The imported product or variation object.
Args: 2
File: src/WooCommerce/InventorySync.php
perflocale/woocommerce/skip_inventory_sync
Skip inventory sync for a specific product.
Parameters:
bool $skip- Whether to skip inventory sync for this product. Not a flatfalse: the incoming value is the per-product opt-out meta (_perflocale_sync_optout=yes), which a variation also inherits from its parent product. Return$skipunchanged to preserve the user's opt-out.int $product_id- Product being synced.
File: src/WooCommerce/InventorySync.php
perflocale/woocommerce/translate_string
Override string translation for WooCommerce gateway/shipping titles.
Parameters:
string|null $translated- Translated string or null (no override).string $original- Original string.string $slug- Current language slug.string $context- Translation context (gateway ID or empty).
Args: 4
File: src/WooCommerce/WcStringTranslation.php
perflocale/woocommerce/translatable_email_ids
Filter the WooCommerce email IDs whose subjects, headings, and additional content are translatable via the String Translation system.
add_filter( 'perflocale/woocommerce/translatable_email_ids', function ( array $ids ): array {
$ids[] = 'customer_stock_notification'; // Add a custom WC email type.
return $ids;
} );Parameters: array $ids - Array of WooCommerce email ID strings.
Default: new_order, cancelled_order, failed_order, customer_on_hold_order, customer_processing_order, customer_completed_order, customer_refunded_order, customer_invoice, customer_note
Args: 1
File: src/WooCommerce/EmailTranslation.php
Gravity Forms
perflocale/gravity_forms/form_translations
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.
add_filter( 'perflocale/gravity_forms/form_translations', function ( array $translations, int $form_id, string $language_slug ): array {
if ( $form_id === 3 && $language_slug === 'de' ) {
$translations['fields']['1']['label'] = 'Vollständiger Name';
}
return $translations;
}, 10, 3 );Parameters: array $translations - Stored translations for this form + language (empty when none). Shape: [ 'fields' => [ field_id => [ 'label', 'description', 'placeholder', 'choices' ] ], 'confirmations' => [ id => [ 'message', 'url' ] ] ].int $form_id - Gravity Forms form id.string $language_slug - Current PerfLocale language slug.
File: addons/gravity-forms/PerfLocaleGravityForms.php
GeoIP Redirect
perflocale/redirect/priority_order
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.
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;
} );Parameters: 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.
File: src/Settings.php
perflocale/geo/providers
Register GeoIP providers. 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).
add_filter( 'perflocale/geo/providers', function ( array $providers ): array {
$providers['my_provider'] = [
'name' => 'My GeoIP Provider',
'needs_key' => true,
'key_setting' => 'geo_my_provider_key',
'fetch_callback' => function ( string $ip, \PerfLocale\Settings $settings ): string {
// Return two-letter country code or ''.
return 'US';
},
];
return $providers;
} );Parameters: array $providers - Associative array of provider definitions.
File: src/Router/GeoRedirect.php
perflocale/geo/country_code
Filter the detected country code after a GeoIP lookup.
Parameters: string $country_code, string $ip
File: src/Router/GeoRedirect.php
perflocale/geo/redirect_language
Override the language slug selected for redirect.
Parameters: string $language_slug, string $country_code, string $ip
File: src/Router/GeoRedirect.php
perflocale/geo/country_map
Filter the country-to-language mapping array.
Parameters: array<string, string> $map - 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.
File: src/Router/GeoRedirect.php
perflocale/geo/should_redirect
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.
// Skip GeoIP redirects for logged-in users, admin URLs, and affiliate campaigns.
add_filter( 'perflocale/geo/should_redirect', function ( bool $should ): bool {
if ( is_user_logged_in() ) {
return false; // Respect the visitor’s saved preference.
}
if ( ! empty( $_GET['utm_campaign'] ) ) {
return false; // Don’t second-guess campaign traffic.
}
$path = wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) ?: '/';
if ( str_starts_with( $path, '/api/' ) || str_starts_with( $path, '/webhook/' ) ) {
return false;
}
return $should;
} );Parameters: bool $should_redirect - default true.
File: src/Router/GeoRedirect.php
perflocale/fallback/redirect_status
HTTP status code used when a language fallback redirect fires. Default 302 (temporary). Return 301 for permanent SEO consolidation. Allowed values: {301, 302, 307, 308}; anything else silently reverts to 302.
// 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 );Parameters:
int $status- default 302.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 (0when redirecting to the language homepage).
File: src/Translation/PostQueryFilter.php
Privacy
perflocale/privacy/consent_given
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 - they fire immediately, matching the pre-consent-framework behaviour. (To drop the cookie unconditionally without a consent plugin, use the Cookieless-mode setting instead.)
// 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' );
} );Parameters: bool $granted - default true.
File: src/Router/LanguageRouter.php, src/Router/GeoRedirect.php
REST API
perflocale/api/config
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.
// Expose feature flags + fallback chain to the edge.
add_filter( 'perflocale/api/config', function ( array $payload ): array {
$payload['feature_flags'] = [
'new_checkout' => get_option( 'acme_new_checkout_enabled', false ),
'promo_banner' => get_option( 'acme_promo_banner_active', false ),
];
$payload['fallback_chain'] = [
'en-gb' => [ 'en-us', 'en' ],
'de-at' => [ 'de', 'en' ],
];
return $payload;
} );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: 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).
File: src/Api/ConfigController.php
perflocale/api/languages_public
Whether the public read endpoints GET /perflocale/v1/languages and GET /perflocale/v1/languages/{slug} may be accessed anonymously. 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.
// Block anonymous reads of /languages.
add_filter( 'perflocale/api/languages_public', '__return_false' );Parameters:
bool $public- defaulttrue.WP_REST_Request $request- current request.
Args: 2
File: src/Api/LanguagesController.php
perflocale/edge_worker/config_permission_callback
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.
// 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 );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.
Parameters:
bool|WP_Error $allowed— defaulttrue.WP_REST_Request $request— current request.
Args: 2
File: src/Api/ConfigController.php
Admin
perflocale/admin/filter_terms_checklist
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).
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 );Parameters: bool $enabled, int $post_id, array $args.
File: src/Admin/MetaBox.php
perflocale/admin/post_list_columns
Filter the columns added to the post list table.
Parameters:
array $columns- Column definitions.string $post_type- Current post type.
File: src/Admin/PostListColumns.php
perflocale/predefined_languages
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-safe slug. Maximum 10 characters, must be unique across all entries.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- PHPdate()format string. Often locale-specific (e.g.'j F Y'for European day-month-year).time_format- PHPdate()format string (e.g.'g:i a','H:i').
Add a custom language:
add_filter( 'perflocale/predefined_languages', function ( array $list ): array {
$list[] = [
'slug' => 'tlh',
'locale' => 'tlh_KL',
'name' => 'Klingon',
'native_name' => 'tlhIngan Hol',
'flag' => '',
'text_direction' => 'ltr',
'date_format' => 'F j, Y',
'time_format' => 'g:i a',
];
return $list;
} );Restrict the picker to a curated short-list (e.g. for editors who should only ever pick from your supported locales):
add_filter( 'perflocale/predefined_languages', function ( array $list ): array {
$allowed = [ 'en', 'es', 'fr', 'de', 'ja' ];
return array_values( array_filter(
$list,
fn( array $entry ) => in_array( $entry['slug'] ?? '', $allowed, true )
) );
} );Override a bundled entry (e.g. swap the default date_format for German to ISO 8601):
add_filter( 'perflocale/predefined_languages', function ( array $list ): array {
foreach ( $list as &$entry ) {
if ( ( $entry['slug'] ?? '' ) === 'de' ) {
$entry['date_format'] = 'Y-m-d';
}
}
return $list;
} );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: array<int, array<string, string>> $predefined - Bundled languages as loaded from data/languages.php (190+ entries in 1.0.0).
File: src/Admin/Pages/LanguagesPage.php
Roles & Permissions
perflocale/roles/editor_caps
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.
Parameters: array<string, bool> $caps - Map of capability => grant. Default: perflocale_translate, perflocale_manage_translations, perflocale_approve_translations, perflocale_use_mt.
File: src/Admin/TranslatorRole.php
// 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,
];
} );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.
perflocale/roles/cap_roles
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: string[] $roles - Array of role slugs. Default: ['administrator', 'editor'] on deactivation, ['editor', 'administrator'] on uninstall (same set, different order).
File: src/Admin/TranslatorRole.php, src/Database/SiteCleanup.php
// 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;
} );Performance & Caching
perflocale/cache/eager_map_row_cap
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.
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 );Parameters: int $cap (default 2000), \PerfLocale\Enum\ObjectType $type.
File: src/Database/Repository/TranslationGroupRepository.php
perflocale/cache/eager_map_byte_cap
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.
add_filter( 'perflocale/cache/eager_map_byte_cap', static fn (): int => 1024 * 1024 ); // 1 MB ceilingParameters: int $cap (default 768000), \PerfLocale\Enum\ObjectType $type.
File: src/Database/Repository/TranslationGroupRepository.php
Background Jobs
Filters that tune the background-processing system. See the Background Jobs doc for the full feature reference.
perflocale/jobs/threshold/<type>
Per-type override of the Auto-mode threshold. 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.
// 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 );Parameters: int $base (resolved from settings or default), array $args (the dispatch args).
File: src/Background/AbstractJob.php
perflocale/jobs/max_attempts
Maximum retry count before a failed job stops being rescheduled. Default 5 (initial attempt + 4 retries).
add_filter( 'perflocale/jobs/max_attempts', static fn(): int => 1 ); // one-shot, never retryParameters: int $max.
File: src/Background/WorkerRegistry.php
perflocale/jobs/retry_delay
Seconds to wait before the next retry attempt. Default: exponential backoff capped at 1 hour — min(3600, 60 * 2^(attempts-1)).
add_filter( 'perflocale/jobs/retry_delay', static function ( int $delay, int $attempts ): int {
return 60 * max( 1, $attempts ); // linear backoff
}, 10, 2 );Parameters: int $delay, int $attempts.
File: src/Background/WorkerRegistry.php
perflocale/jobs/max_concurrent/<type>
Per-type concurrency cap. Default 1 (one worker per type at a time). Set > 1 to allow parallel workers; PHP_INT_MAX disables the cap entirely.
// Allow up to 4 parallel string scans.
add_filter( 'perflocale/jobs/max_concurrent/string_scan', static fn(): int => 4 );Parameters: int $max.
File: src/Background/WorkerRegistry.php
perflocale/jobs/deduplicate_admission
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.
// 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 );Parameters: bool $enabled, string $type, array $args.
File: src/Background/Dispatcher.php
perflocale/jobs/max_args_bytes
Maximum JSON-encoded size of the args payload accepted by Dispatcher::enqueue(). Default 100 KB. Hardening against options-table bloat.
Parameters: int $bytes (clamped to a minimum of 1024).
File: src/Background/Dispatcher.php
perflocale/jobs/max_args_depth
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.
// A custom job that ships a genuinely nested config tree.
add_filter( 'perflocale/jobs/max_args_depth', static fn(): int => 40 );Parameters: int $depth (default 20, clamped to a minimum of 4).
File: src/Background/Dispatcher.php
perflocale/jobs/active_index_max
Cap on the active-jobs index size (the rows visible on PerfLocale → Jobs). Default 50. Each row is ~200 bytes, so a cap of 250 costs ~50 KB in a non-autoloaded option. Floor-clamped to 10.
// Sites with very high dispatch throughput can show more history.
add_filter( 'perflocale/jobs/active_index_max', static fn(): int => 250 );Parameters: int $max (default 50, clamped to 10 minimum).
File: src/Background/JobState.php
perflocale/jobs/resume_max
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.
add_filter( 'perflocale/jobs/resume_max', static fn(): int => 5000 );Parameters: int $max (default 1000, clamped to 1 minimum).
File: src/Background/JobState.php
perflocale/import/max_file_bytes
Maximum size of the uploaded JSON import envelope. Default 52428800 (50 MB). Floor-clamped to 1 MB.
add_filter( 'perflocale/import/max_file_bytes', static fn(): int => 200 * 1024 * 1024 ); // 200 MBParameters: int $bytes.
File: src/Admin/AdminController.php
perflocale/export/batch_size
Rows fetched per LIMIT clause during the streaming JSON export. Default 1000. Bottleneck is usually wp_json_encode, not the SELECT. Clamped to 50–10000. Per-table override via the second arg.
add_filter( 'perflocale/export/batch_size', static function ( int $size, string $table ): int {
return $table === 'strings' ? 5000 : 1000;
}, 10, 2 );Parameters: int $size, string $table.
File: src/Admin/DataExporter.php
perflocale/migration/translatepress/batch_size
Posts processed per transaction during the TranslatePress migration. Default 50. Clamped to 5–500.
add_filter( 'perflocale/migration/translatepress/batch_size', static fn(): int => 200 );Parameters: int $size.
File: src/Migration/TranslatePressImporter.php
perflocale/migration/wpml/batch_size
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.
add_filter( 'perflocale/migration/wpml/batch_size', static fn(): int => 250 );Parameters: int $size.
File: src/Migration/WpmlImporter.php
perflocale/migration/polylang/batch_size
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.
add_filter( 'perflocale/migration/polylang/batch_size', static fn(): int => 250 );Parameters: int $size.
File: src/Migration/PolylangImporter.php
perflocale/migration/wpml_string_batch_size
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 — on a 50k-string WPML export the previous one-shot SELECT consumed ~25 MB of PHP arrays before any work began. 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).
add_filter( 'perflocale/migration/wpml_string_batch_size', static fn(): int => 1000 );Parameters: int $size.
File: src/Migration/WpmlImporter.php
perflocale/migration/translatepress/gettext_batch_size
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; previously the importer used a flat LIMIT 10000 that both capped peak memory at ~5 MB per language AND silently truncated sites with more than 10k gettext strings per language. The keyset cursor removes the silent-truncation failure mode entirely; lowering the batch size further reduces memory at the cost of more SQL roundtrips. Default 1000. Floor-clamped to 100.
add_filter( 'perflocale/migration/translatepress/gettext_batch_size', static fn(): int => 2500 );Parameters: int $size.
File: src/Migration/TranslatePressImporter.php
perflocale/migration/translatepress/min_status
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.
// Import only human-reviewed TranslatePress translations.
add_filter( 'perflocale/migration/translatepress/min_status', static fn(): int => 2 );Parameters: int $status - Default 1 (machine-translated). Set to 2 for reviewed-only.
File: src/Migration/TranslatePressImporter.php
perflocale/strings/scanner/max_file_bytes
Maximum size of a single PHP file the string scanner will read. Files larger than this are skipped to keep peak memory bounded. Default 2097152 (2 MB). Floor-clamped to 64 KB.
add_filter( 'perflocale/strings/scanner/max_file_bytes', static fn(): int => 10 * 1024 * 1024 ); // 10 MBParameters: int $bytes.
File: src/Strings/StringScanner.php
perflocale/strings/scanner/batch_size
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).
add_filter( 'perflocale/strings/scanner/batch_size', static fn(): int => 2000 );Parameters: int $size.
File: src/Strings/StringScanner.php
perflocale/jobs/stuck_timeout_seconds
How long a job may sit in queued or running without its updated_at being bumped before the daily GC declares it stuck and marks it failed. Default 6 hours.
Parameters: int $seconds.
File: src/Background/JobState.php
perflocale/jobs/pause_recheck_seconds
When the queue is paused, workers that pick up a job re-schedule it this many seconds later instead of running. Default 300 (5 minutes).
Parameters: int $seconds.
File: src/Background/WorkerRegistry.php
perflocale/jobs/pause_refresh_window
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.
Parameters: int $seconds (default 10, minimum 1).
File: src/Background/WorkerRegistry.php
perflocale/jobs/type_busy_retry_seconds
When the per-type concurrency lock is held by another worker, the new attempt re-queues this many seconds later. Default 60.
Parameters: int $seconds.
File: src/Background/WorkerRegistry.php
perflocale/jobs/type_busy_max_seconds
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.
add_filter( 'perflocale/jobs/type_busy_max_seconds', static fn(): int => 1800 ); // 30 min capParameters: int $seconds (default 600).
File: src/Background/WorkerRegistry.php
perflocale/jobs/lock_busy_max_retries
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.
// 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 );Parameters: int $max.
File: src/Background/WorkerRegistry.php
perflocale/jobs/lock_busy_max_seconds
Upper bound on the delay between lock-busy retries (the delay grows exponentially with jitter; this caps it). Default 600s (10 min).
add_filter( 'perflocale/jobs/lock_busy_max_seconds', static fn(): int => 1800 ); // 30 min capParameters: int $seconds.
File: src/Background/WorkerRegistry.php
perflocale/jobs/runner
Globally override the runner instance. For tests, custom deployments, or third-party schedulers (Sidekiq, SQS, etc.). Return a JobRunnerInterface instance to bypass the engine setting and Action Scheduler detection.
add_filter( 'perflocale/jobs/runner', static function ( $default ) {
return new MyCustomQueueRunner();
}, 99 );Parameters: JobRunnerInterface|null $override.
File: src/Background/JobRunnerFactory.php
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.
perflocale/po/max_bytes
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.
Parameters: int $bytes.
File: src/Admin/AdminController.php
perflocale/jobs/should_dispatch
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.
// Block all jobs between Friday 17:00 UTC and Monday 09:00 UTC (deploy freeze).
add_filter( 'perflocale/jobs/should_dispatch', static function ( $proceed, \PerfLocale\Background\AbstractJob $job, array $args ) {
$dow = (int) gmdate( 'w' );
$hour = (int) gmdate( 'H' );
$frozen = ( $dow === 5 && $hour >= 17 ) || $dow === 6 || ( $dow === 0 ) || ( $dow === 1 && $hour < 9 );
return $frozen ? 'Deploy freeze in effect — queued operations resume Monday 09:00 UTC.' : $proceed;
}, 10, 3 );
// Per-tenant quota: cap bulk MT dispatches at 1000 source x target pairs per day.
add_filter( 'perflocale/jobs/should_dispatch', static function ( $proceed, $job, $args ) {
if ( $job->get_type() !== 'bulk_translate' ) { return $proceed; }
$today_count = (int) get_transient( 'perflocale_bulk_mt_quota_' . gmdate( 'Y-m-d' ) );
$cost = count( $args['source_ids'] ?? [] ) * count( $args['target_lang_ids'] ?? [] );
if ( $today_count + $cost > 1000 ) {
return 'Daily bulk-MT quota exhausted (1000 pairs/day).';
}
set_transient( 'perflocale_bulk_mt_quota_' . gmdate( 'Y-m-d' ), $today_count + $cost, DAY_IN_SECONDS );
return $proceed;
}, 10, 3 );Parameters:
bool $proceed— defaulttrue.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.
File: src/Background/Dispatcher.php
Reliability & Circuit Breakers
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.
perflocale/breaker/disabled
Global kill-switch. 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.
// Disable all breakers (debugging only — re-enable as soon as possible).
add_filter( 'perflocale/breaker/disabled', '__return_true' );Parameters: bool $disabled — Default false.
File: src/Concurrency/Breaker.php
perflocale/breaker/threshold
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).
// 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 );Parameters: int $threshold — Default 5. Floor 1.
File: src/Concurrency/Breaker.php
perflocale/breaker/window_seconds
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>.
// 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 );Parameters: int $seconds — Default 300. Floor 1.
File: src/Concurrency/Breaker.php
perflocale/breaker/cooldown_seconds
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>.
// 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 );Parameters: int $seconds — Default 300. Floor 1.
File: src/Concurrency/Breaker.php
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.
perflocale/breaker/probe_lease_seconds
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.
// 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 );Parameters: int $seconds, string $key.
File: src/Concurrency/Breaker.php
BreakerOpenException (catchable)
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.
try {
$translated = $mt_service->translate_text( $text, 'en', 'de' );
} catch ( \PerfLocale\Concurrency\BreakerOpenException $e ) {
// Provider is being rate-limited / auth-rejected / 5xx'd.
// $e->get_breaker_key() returns "mt_deepl" or similar.
// $e->getMessage() is human-readable (includes the retry-in seconds).
$translated = my_fallback_cached_translation( $text );
} catch ( \RuntimeException $e ) {
// Genuine downstream error (not a breaker pre-emption) — log + skip.
}Class: \PerfLocale\Concurrency\BreakerOpenException extends \RuntimeException
File: src/Concurrency/BreakerOpenException.php
Export & Import
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.
perflocale/export/sections
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.
// 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 );Parameters:
array $sections— existing sections (empty by default). Keyed by section name; each value is JSON-serialisable.array $context—{ requested: string[], format_version: int }.requestedis the user-chosen section list (skip your section if it's not requested);format_versionis the envelope schema version (always1in v1.0).
Returns: array<string, mixed>.
File: src/Admin/DataExporter.php
Machine Translation (bulk)
perflocale/mt/bulk/before_translate
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 (theafter_translateaction still fires, withshort_circuited => true). - Return
null— default flow runs.
// 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 );Parameters:
mixed $pre—nullby 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 }.provideris the configured MT provider key (deepl/google/ etc.).processed/totallet you log progress.
Returns: mixed (see semantics above).
File: src/Background/Jobs/BulkTranslateJob.php
perflocale/mt/bulk_string_max_per_dispatch
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.
// Roomy host with budget: allow up to 20,000 strings per bulk dispatch.
add_filter( 'perflocale/mt/bulk_string_max_per_dispatch', fn() => 20000 );Parameters:
int $cap— the ceiling,5000by default. The return value is cast toint.
Returns: int — the maximum number of strings a single dispatch may translate.
File: src/Background/Jobs/BulkStringTranslateJob.php
Actions
Caching
perflocale/cache/purge_urls
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.
add_action( 'perflocale/cache/purge_urls', function ( array $urls, int $post_id ): void {
foreach ( $urls as $url ) {
my_page_cache_purge( $url );
}
}, 10, 2 );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: array $urls - Absolute front-end URLs, language prefixes included.int $post_id - The post whose visibility changed.
File: src/Cache/CacheInvalidator.php
Plugin Lifecycle
perflocale/activated
Fires after the plugin is activated.
Parameters: string $version - Plugin version.
File: src/Activator.php
perflocale/activation/chunk_size
Sites fetched per iteration during multisite network activation. PerfLocale activates each subsite in chunks so a network with tens of thousands of sites doesn't load every row at once. Lower this on memory-constrained PHP workers; raise it if you've benchmarked your activation hook and want fewer round-trips.
add_filter( 'perflocale/activation/chunk_size', fn() => 25 );Parameters: int $chunk - Default 100. Floored at 1.
File: perflocale.php
perflocale/deactivated
Fires after the plugin is deactivated.
File: src/Deactivator.php
perflocale/loaded
Fires after PerfLocale is fully bootstrapped and all services are registered.
add_action( 'perflocale/loaded', function ( \PerfLocale\Plugin $plugin ): void {
// Safe to use all PerfLocale services here.
$router = $plugin->get( 'router' );
} );Parameters: Plugin $plugin - The plugin container instance.
File: src/Bootstrap.php
perflocale/upgraded
Fires after a database schema migration completes.
Parameters:
string $old_version- Previous schema version.string $new_version- New schema version.
Args: 2
File: src/Database/Migrator.php
perflocale/updated
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.
add_action( 'perflocale/updated', function ( string $old_version, string $new_version ): void {
if ( version_compare( $old_version, '1.1.0', '<' ) ) {
// One-time task for 1.1.0 update.
}
}, 10, 2 );Parameters:
string $old_version- Previous plugin version.string $new_version- Current plugin version.
Args: 2
File: src/Database/Migrator.php
perflocale/strings/after_scan
Fires after the "Scan for Strings" action completes. Addons hook here to register non-gettext translatable strings (e.g., WooCommerce email subjects, attribute labels).
add_action( 'perflocale/strings/after_scan', function (): void {
// Register custom non-gettext strings here.
} );Args: 0
File: src/Background/Jobs/StringScanJob.php
Language Events
perflocale/language/detected
Fires after the current language is detected from the request.
add_action( 'perflocale/language/detected', function ( string $slug, string $method ): void {
// $method is 'url', 'cookie', 'browser', 'edge_hint', or 'default'.
if ( $method === 'browser' ) {
// First-time visitor, language detected from browser.
}
}, 10, 2 );Parameters:
string $slug- Detected language slug.string $method- Detection method used.
File: src/Router/LanguageRouter.php
perflocale/language/overridden
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).
Parameters: none.
File: src/Router/LanguageRouter.php
perflocale/language/added
Fires after a new language is added.
Parameters: object $language - The language object.
File: src/Database/Repository/LanguageRepository.php
perflocale/language/updated
Fires after a language is updated.
Parameters:
object $language- Updated language object.array $old- Previous language data.
File: src/Database/Repository/LanguageRepository.php
perflocale/language/deleted
Fires after a language is deleted.
Parameters:
int $id- Language ID.string $slug- Language slug.
File: src/Database/Repository/LanguageRepository.php
perflocale/default_language/changed
Fires after the default language is changed.
Parameters:
object $new_default- New default language.object $old_default- Previous default language.
File: src/Database/Repository/LanguageRepository.php
perflocale/language/slug_renamed
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.
Parameters:
int $id- Language row ID.string $old_slug- Previous slug.string $new_slug- Renamed slug.
File: src/Database/Repository/LanguageRepository.php
perflocale/languages/reordered
Fires after the Languages-list drag-and-drop reorder UI persists a new sort_order for one or more rows.
Parameters:
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.
File: src/Database/Repository/LanguageRepository.php
Translation Events
perflocale/translation/created
Fires after a translation post or term is created.
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 );Parameters:
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.
Args: 4
File: src/Translation/PostTranslationManager.php, src/Translation/TermTranslationManager.php
perflocale/translation/meta_copy_failed
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.
add_action( 'perflocale/translation/meta_copy_failed', function ( int $new_post_id, int $source_id, string $target_slug, array $errors ): void {
foreach ( $errors as $err ) {
// $err = [ 'step' => 'post_meta'|'featured_image'|'taxonomy_terms', 'message' => string ]
error_log( sprintf(
'PerfLocale copy step "%s" failed for translation %d (source %d, %s): %s',
$err['step'], $new_post_id, $source_id, $target_slug, $err['message']
) );
}
}, 10, 4 );Parameters:
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 }.
Args: 4
File: src/Translation/PostTranslationManager.php
perflocale/slug/write_failed
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.
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 );Parameters:
string $reason— one of:'empty_slug'— the slug sanitised down to nothing (for example a title made entirely of punctuation or of characterssanitize_title()strips).'duplicate_exhausted'— the slug and all 99-2…-100suffixes 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, fordb_error,db_errorwith the database message.
Args: 2
File: src/Database/Repository/SlugTranslationRepository.php
perflocale/translation/linked
Fires when an object is linked to a translation group.
Parameters:
int $group_id- Translation group ID.int $object_id- Object ID.int $language_id- Language ID.
File: src/Database/Repository/TranslationGroupRepository.php
perflocale/translation/status_changed
Fires when a translation's status changes (e.g., draft to published).
Parameters:
int $object_id- Object ID.string $status- New status.int $language_id- Language ID.
File: src/Database/Repository/TranslationGroupRepository.php
perflocale/content/changed
Fires when source content changes, marking translations as potentially outdated.
Parameters:
int $object_id- Object ID.string $type- Object type value.int $group_id- Translation group ID.
File: src/Translation/ContentChangeDetector.php
Settings
perflocale/settings/updated
Fires after plugin settings are saved.
Parameters:
array $merged- Merged (new) settings array.array $current- Previous settings array.
File: src/Settings.php
Translation Status
perflocale/translations/bulk_marked_needs_update
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:
array $source_ids- Source post IDs.array $target_lang_ids- Target language IDs (single ID or all active languages).int $count— Number of siblingtranslation_linksrows the action matched in the target languages, not the number MySQL reported as changed. A row already sitting atneeds_updateis matched but not changed, and reporting the changed count would tell the operator “0 translations flagged” over a selection that did match.0also means the write failed — the caches are deliberately left alone in that case, because the stored statuses are still the cached ones.
File: src/Admin/AdminController.php
Machine Translation
perflocale/machine_translation/before
Fires before machine translation starts for a post.
Parameters:
int $post_id- Post ID.string $provider_id- Provider identifier.
File: src/MachineTranslation/TranslationService.php
perflocale/machine_translation/after
Fires after machine translation completes successfully.
Parameters:
int $post_id- Source post ID.string $provider_id- Provider identifier.array $result- Translation result (title,content,excerpt,post_id; plusmetacounts when meta translation ran).string $target_lang- Target language slug.string $source_lang- Source language slug.WP_Post $post- Source post object.
File: src/MachineTranslation/TranslationService.php
perflocale/mt/meta_translate_failed
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.
Parameters:
int $source_id- Source post ID.int $translation_id- Translation post ID.string[] $keys- Meta keys involved.string $message- Human-readable summary.
File: src/MachineTranslation/MetaTranslator.php
perflocale/machine_translation/failed
Fires when machine translation fails with an exception.
Parameters:
int $post_id- Post ID.string $provider_id- Provider identifier.Exception $exception- The exception.
File: src/MachineTranslation/TranslationService.php
Cache
perflocale/cache/flush_all
Fires after the entire plugin cache is flushed.
File: src/Cache/CacheManager.php
perflocale/cache/flush_object
Fires after cache is flushed for a specific object.
Parameters:
int $object_id- Object ID.string $object_type- Object type.
File: src/Cache/CacheManager.php
perflocale/cache/flush_translations
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.
Parameters: none
File: src/Cache/CacheManager.php
Background Jobs
Lifecycle actions for the background-processing system. See the Background Jobs doc for the full feature reference.
perflocale/jobs/enqueued
Fires after a job has been successfully enqueued for async execution.
add_action( 'perflocale/jobs/enqueued', static function ( string $job_id, string $type, string $engine, array $args ): void {
error_log( "PerfLocale enqueued $type job $job_id on $engine" );
}, 10, 4 );Parameters:
string $job_id— UUID v4 of the new job.string $type— job type slug (e.g.string_scan,data_import).string $engine— runner engine:action_schedulerorwp_cron.array $args— the dispatch args.
File: src/Background/Dispatcher.php
perflocale/jobs/completed
Fires after a worker finishes a job successfully.
add_action( 'perflocale/jobs/completed', static function ( string $job_id, string $type, array $result ): void {
// Push to your metrics pipeline...
}, 10, 3 );Parameters: string $job_id, string $type, array $result (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).
File: src/Background/WorkerRegistry.php
perflocale/jobs/failed
Fires when a worker throws an exception and the retry-with-backoff is about to be scheduled (or skipped because the attempt cap has been hit). Receives the FULL untruncated throwable, useful for monitoring — the version persisted to the job row is path-redacted and truncated.
add_action( 'perflocale/jobs/failed', static function ( string $job_id, string $type, \Throwable $e ): void {
sentry_capture_exception( $e, [
'tags' => [ 'perflocale_job_id' => $job_id, 'perflocale_job_type' => $type ],
] );
}, 10, 3 );Parameters: string $job_id, string $type, \Throwable $e.
File: src/Background/WorkerRegistry.php
perflocale/jobs/canceled
Fires when a long-running worker cooperatively aborts itself in response to an operator cancel mid-flight. Distinct from perflocale/jobs/failed because it isn't an error — useful for distinguishing operator-canceled from worker-errored in dashboards.
Parameters: string $job_id, string $type.
File: src/Background/WorkerRegistry.php
Export & Import
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.
perflocale/export/written
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.
// 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 );Parameters:
string $path— absolute path to the written export file (always insideuploads/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, …).
File: src/Background/Jobs/DataExportJob.php
perflocale/export/download/before_serve
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.
add_action( 'perflocale/export/download/before_serve', static function ( string $job_id, string $path, array $job ): void {
my_audit_log( [
'event' => 'export_download',
'user_id' => get_current_user_id(),
'job_id' => $job_id,
'size' => filesize( $path ),
] );
}, 10, 3 );Parameters: string $job_id, string $real_path, array $job (the JobState row).
File: src/Admin/AdminController.php
perflocale/export/download/after_serve
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.
// 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 );Parameters: string $job_id, string $real_path, array $job, int $size (bytes served, 0 when filesize() failed).
File: src/Admin/AdminController.php
perflocale/import/section/<name>
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.
// 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 );Parameters:
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 }.replacemirrors the import's replace-mode flag; truncate your tables when it'strue.file_pathis the path of the source envelope.
File: src/Admin/DataImporter.php
perflocale/import/completed
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.
add_action( 'perflocale/import/completed', static function ( array $result, string $file_path, bool $replace ): void {
if ( ! empty( $result['errors'] ) ) { return; }
wp_remote_post( 'https://hooks.slack.com/...', [
'body' => wp_json_encode( [
'text' => sprintf(
'PerfLocale import done: %d rows imported, %d skipped (replace=%s).',
$result['imported'],
$result['skipped'],
$replace ? 'yes' : 'no'
),
] ),
] );
}, 10, 3 );Parameters: array $result ({ imported: int, skipped: int, errors: string[] }), string $file_path, bool $replace.
File: src/Admin/DataImporter.php
Machine Translation (bulk)
perflocale/mt/bulk/after_translate
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.
// Per-row APM metric for bulk MT.
add_action( 'perflocale/mt/bulk/after_translate', static function ( int $source_id, string $target_slug, array $result, array $ctx ): void {
my_apm_increment( 'perflocale.mt.bulk.row', [
'provider' => $ctx['provider'] ?: 'unknown',
'target' => $target_slug,
'outcome' => ! empty( $result['post_id'] ) ? 'created' : ( $ctx['short_circuited'] ? 'skipped_filter' : 'failed' ),
'short_circuited' => $ctx['short_circuited'] ? '1' : '0',
] );
}, 10, 4 );Parameters:
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 thebefore_translatecontext plus two extra keys:short_circuited: bool— true if the row was handled by abefore_translatefilter callback.error: string— the exception message or fallback error text; empty string on success.
File: src/Background/Jobs/BulkTranslateJob.php
Addons
perflocale/addons/cache/flush
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.
// Force a rebuild of the bootable-addons cache.
do_action( 'perflocale/addons/cache/flush' );Parameters: none
File: src/Addon/AddonRegistry.php
perflocale/addon/activated
Fires when an addon is activated (booted).
Parameters: string $addon_id - Addon identifier.
File: src/Addon/AddonRegistry.php
perflocale/addons/loaded
Fires after all addons have been loaded.
File: src/Addon/AddonRegistry.php
perflocale/addon/seeded
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).
add_action( 'perflocale/addon/seeded', function ( string $addon_id, array $defaults ): void {
if ( $addon_id !== 'my-addon' ) {
return;
}
// Schedule the welcome email once.
wp_schedule_single_event( time() + 60, 'myaddon/welcome_email' );
}, 10, 2 );Parameters:
string $addon_id— Addon'sget_id()value.array $defaults— The defaults that were seeded.
File: src/Addon/AddonRegistry.php
perflocale/addon/settings/before_save
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.
add_action( 'perflocale/addon/settings/before_save', function ( string $addon_id, array $new, array $old ): void {
error_log( sprintf(
'addon=%s changed=%s',
$addon_id,
wp_json_encode( array_diff_assoc( $new, $old ) )
) );
}, 10, 3 );Parameters:
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).
File: src/Addon/AddonSettings.php
perflocale/addon/settings/after_save
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:
add_action( 'perflocale/addon/settings/after_save', function ( string $addon_id, array $new, array $old ): void {
if ( $addon_id !== 'woocommerce' ) {
return;
}
// Schedule a cache rebuild OUTSIDE the lock window.
wp_schedule_single_event( time() + 5, 'myaddon/rebuild_currency_cache' );
}, 10, 3 );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.
Parameters: identical to before_save.
File: src/Addon/AddonSettings.php
WooCommerce
perflocale/woocommerce/exchange_rates_synced
Fires after exchange rates are successfully fetched and saved.
Parameters:
array $rates- Currency code => exchange rate.string $base_currency- WooCommerce base currency code.string $provider_id- Provider that was used.
File: src/WooCommerce/ExchangeRateSync.php
perflocale/woocommerce/inventory_synced
Fires after inventory fields are synced across language variants.
Parameters:
int $product_id- Source product ID. For the variation-sync paths this is the source variation ID, not the parent product.array $synced_ids- IDs of sibling translations that were updated.array $fields- Meta keys that were synced.
File: src/WooCommerce/InventorySync.php
GeoIP Redirect
perflocale/geo/redirected
Fires after a GeoIP redirect has been performed.
Parameters:
string $language_slug- The language the visitor was redirected to.string $country_code- Detected country code.string $ip- Visitor IP address.
File: src/Router/GeoRedirect.php
Edge Integration (filters)
perflocale/edge/enabled
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.
add_filter( 'perflocale/edge/enabled', '__return_true' );Parameters: bool $enabled - Current effective state.
File: src/Settings.php
perflocale/edge/hint_header
Rename the HTTP header used to carry the edge-selected language.
add_filter( 'perflocale/edge/hint_header', fn() => 'X-Vercel-Lang' );Parameters: string $header_name - Default X-PerfLocale-Lang.
File: src/Router/LanguageRouter.php
perflocale/edge/hint_cookie
Rename the cookie used as fallback for edge-selected language.
add_filter( 'perflocale/edge/hint_cookie', fn() => 'my_lang_cookie' );Parameters: string $cookie_name - Default perflocale_edge_lang.
File: src/Router/LanguageRouter.php
perflocale/edge/accept_hint
Veto a specific edge-hint per request. Return false to reject an otherwise valid hint (e.g. behind a reverse proxy that mis-forwards headers).
add_filter( 'perflocale/edge/accept_hint', function ( bool $accept, string $slug ): bool {
if ( ! empty( $_SERVER['HTTP_X_INTERNAL_PROBE'] ) ) {
return false;
}
return $accept;
}, 10, 2 );Parameters:
bool $accept- Defaulttrue.string $slug- Candidate language slug from header/cookie.
File: src/Router/LanguageRouter.php
Garbage Collection & Retention
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.
perflocale/strings/gc_complete
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.
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 );Parameters:
int $strings_deleted— rows deleted fromperflocale_strings.int $translations_deleted— rows deleted fromperflocale_string_translationsvia the cascade.
File: src/Database/Repository/StringRepository.php
perflocale/string_translations/orphans_swept
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.
// 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.' );
} );Parameters: int $deleted — number of orphan rows deleted.
File: src/Database/Repository/StringTranslationRepository.php
CDN Cache-Tag Headers (filters + action)
perflocale/cache_tags/enabled
Programmatic override for Cache-Tag header emission. Returning false kills the feature without touching the setting.
Parameters: bool $enabled - Current effective state.
File: src/Settings.php
perflocale/cache_tags/header_name
Change the response-header name. Useful for Fastly (Surrogate-Key) or custom CDNs.
add_filter( 'perflocale/cache_tags/header_name', fn() => 'Surrogate-Key' );Parameters: string $name - Default Cache-Tag.
File: src/Frontend/CacheTagEmitter.php
perflocale/cache_tags/tags
Modify the list of tags emitted for the current request. Tags are ASCII-only ([A-Za-z0-9\-_:.]), truncated per-entry to 128 chars and capped at 32 per response.
add_filter( 'perflocale/cache_tags/tags', function ( array $tags ): array {
$tags[] = 'theme:' . get_template();
return $tags;
} );Parameters: array $tags - Sanitised tag list.
File: src/Frontend/CacheTagEmitter.php
perflocale/cache_tags/max_header_length
Response-header byte budget (default 8000). Tags overflowing the budget are dropped silently.
Parameters: int $max - Default 8000.
File: src/Frontend/CacheTagEmitter.php
perflocale/cache/flush_all (action)
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).
add_action( 'perflocale/cache/flush_all', function (): void {
my_cdn_purge_zone();
} );Parameters: none
File: src/Cache/CacheManager.php
perflocale/cache/flush_object (action)
Fires after PerfLocale invalidates a single object’s cache (post or term). Pair with the cache_tags/tags filter to map the object to your CDN tags and issue a targeted purge.
add_action( 'perflocale/cache/flush_object', function ( int $object_id, string $object_type ): void {
$tag = $object_type . ':' . $object_id;
my_cdn_purge_tags( [ $tag ] );
}, 10, 2 );Parameters: int $object_id, string $object_type (post or term).
File: src/Cache/CacheManager.php
perflocale/cache/flush_slugs (action)
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 autoloaded zero-state flag). 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.
add_action( 'perflocale/cache/flush_slugs', function (): void {
my_cdn_purge_tags( [ 'perflocale:slugs' ] );
} );Parameters: none
File: src/Cache/CacheManager.php
perflocale/cache/flush_archive_hreflang (action)
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).
add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $object_id ): void {
my_cdn_purge_tags( [ 'archive-hreflang:' . $object_id ] );
} );Parameters: 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.
File: src/Cache/CacheInvalidator.php
SEO Schema Enrichment
perflocale/seo/schema_enrichment_enabled
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.
Parameters: bool $enabled - Current effective state.
File: src/Settings.php
Rate Limits & Scan Caps
perflocale/mt/rate_limit
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.
add_filter( 'perflocale/mt/rate_limit', fn() => 1000 );Parameters: int $limit - Default 500 requests per hour.
File: src/Translation/MtRateLimiter.php
perflocale/mt/rate_limit_site
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.
// 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 );Parameters: int $limit - Default 5000 requests per hour, summed across all users. Set to 0 to disable the site cap.
File: src/Translation/MtRateLimiter.php
perflocale/import/quality_limits
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.
// Agency importing a 900-language terminology corpus.
add_filter( 'perflocale/import/quality_limits', fn() => [
'max_languages' => 1000,
'max_rows_per_table' => 2000000,
'max_value_bytes' => 4 * 1048576,
] );Parameters: array $limits - Keys: max_languages (default 500), max_rows_per_table (default 500,000), max_value_bytes (default 1,048,576).
File: src/Admin/DataImporter.php
Environment Gates
perflocale/dispatch/allow_non_production
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.
// 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 );Parameters:
bool $allow- Defaultfalse.string $feature- Dispatch surface. Currently only'webhooks'.string $environment- Currentwp_get_environment_type()value.
File: src/Helper.php
perflocale/dispatch/blocked (action)
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.”
add_action( 'perflocale/dispatch/blocked', static function ( string $feature, string $environment ): void {
error_log( "PerfLocale: {$feature} dispatch muted on {$environment} environment." );
}, 10, 2 );Parameters: string $feature, string $environment.
File: src/Helper.php
Garbage Collection & Retention
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).
perflocale/strings/stale_retention_days
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.
// 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 );Parameters: int $days - Default 90. Set to 0 to disable the GC.
File: src/Database/Repository/StringRepository.php
perflocale/strings/manual_contexts
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.
// 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;
} );Parameters: string[] $contexts - Default [].
File: src/Database/Repository/StringRepository.php
JavaScript hooks (wp.hooks)
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. The toolbar walks this chain and translates the first attribute that returns a non-empty string.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
Additional extension points
The following hooks cover more specialised extension points. They are stable and safe to use in production.
Addons & integrations
perflocale/addons/register
Fires after built-in addons are registered so third parties can register their own addon instances before boot.
add_action( 'perflocale/addons/register', function ( \PerfLocale\Addon\AddonRegistry $registry ): void {
$registry->register( new \My\Plugin\MyAddon() );
} );Parameters: \PerfLocale\Addon\AddonRegistry $registry.
File: src/Addon/AddonRegistry.php
perflocale/addons/registry
Filter the array of known addons as rendered on the Addons admin page.
add_filter( 'perflocale/addons/registry', function ( array $addons ): array {
unset( $addons['legacy-addon-id'] );
return $addons;
} );Parameters: array $addons - Map keyed by addon ID.
File: src/Admin/Pages/AddonsPage.php
perflocale/addon/before_migrate
Fires once per migration step, before the addon's migrate_to() runs.
add_action( 'perflocale/addon/before_migrate', function ( $addon, int $stored, int $target ): void {
error_log( sprintf( 'addon=%s stored=%d -> step=%d', $addon->get_id(), $stored, $target ) );
}, 10, 3 );Parameters: AddonInterface&HasSchema $addon, int $stored, int $target.
Args: 3
File: src/Addon/AddonSchemaManager.php
perflocale/addon/migrated
Fires after each successful addon migration step.
add_action( 'perflocale/addon/migrated', function ( $addon, int $version ): void {
// Warm caches, record telemetry, etc.
}, 10, 2 );Parameters: AddonInterface&HasSchema $addon, int $version.
Args: 2
File: src/Addon/AddonSchemaManager.php
perflocale/addon/migration_failed
Fires when an addon migration step throws. The addon is not quarantined by this filter - observe or re-raise as needed.
add_action( 'perflocale/addon/migration_failed', function ( $addon, int $version, \Throwable $e ): void {
error_log( sprintf( '[perflocale] %s v%d failed: %s', $addon->get_id(), $version, $e->getMessage() ) );
}, 10, 3 );Parameters: AddonInterface&HasSchema $addon, int $version, \Throwable $e.
Args: 3
File: src/Addon/AddonSchemaManager.php
perflocale/addon/manifest_written
Fires after an addon manifest is re-persisted to disk - used by the uninstall pipeline to know what to purge even when the addon is gone.
add_action( 'perflocale/addon/manifest_written', function ( string $addon_id, array $manifest ): void {
// Audit-log manifest changes.
}, 10, 2 );Parameters: string $addon_id, array $manifest.
Args: 2
File: src/Addon/AddonManifestWriter.php
perflocale/addon/before_uninstall
Fires before an addon's stored data is purged. Receives the computed PurgePlan snapshot.
add_action( 'perflocale/addon/before_uninstall', function ( string $addon_id, \PerfLocale\Addon\PurgePlan $plan ): void {
// Back up rows referenced by $plan before PerfLocale drops them.
}, 10, 2 );Parameters: string $addon_id, \PerfLocale\Addon\PurgePlan $plan.
Args: 2
File: src/Addon/AddonUninstaller.php
perflocale/addon/meta_purge_batch
Fires once per batched DELETE during addon uninstall meta cleanup.
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 );Parameters: string $type, int $deleted, int $total.
Args: 3
File: src/Addon/AddonUninstaller.php
perflocale/addon/uninstalled
Fires after an addon's uninstall pipeline completes.
add_action( 'perflocale/addon/uninstalled', function ( string $addon_id, \PerfLocale\Addon\PurgeResult $result ): void {
// $result reports counts per table / meta type.
}, 10, 2 );Parameters: string $addon_id, \PerfLocale\Addon\PurgeResult $result.
Args: 2
File: src/Addon/AddonUninstaller.php
Addon settings UI
perflocale/settings/addon_subtabs
Register a Settings subtab belonging to your addon. Return a map of slug => label.
add_filter( 'perflocale/settings/addon_subtabs', function ( array $subtabs ): array {
$subtabs['my-addon'] = __( 'My Addon', 'my-plugin' );
return $subtabs;
} );Parameters: array $subtabs.
File: src/Admin/AdminController.php
perflocale/settings/render_addon_subtab
Render the form fields for a registered addon subtab.
add_action( 'perflocale/settings/render_addon_subtab', function ( string $subtab, \PerfLocale\Settings $settings ): void {
if ( $subtab !== 'my-addon' ) {
return;
}
// echo <tr> form-table rows here.
}, 10, 2 );Parameters: string $subtab, \PerfLocale\Settings $settings.
Args: 2
File: src/Admin/Pages/SettingsPage.php
perflocale/settings/addon_subtab_after
Fires after the addon subtab form-table, right before the submit button - use for secondary actions.
add_action( 'perflocale/settings/addon_subtab_after', function ( string $subtab, \PerfLocale\Settings $settings ): void {
if ( $subtab === 'my-addon' ) {
echo '<p class="description">' . esc_html__( 'Need help? See our docs.', 'my-plugin' ) . '</p>';
}
}, 10, 2 );Parameters: string $subtab, \PerfLocale\Settings $settings.
Args: 2
File: src/Admin/Pages/SettingsPage.php
perflocale/settings/extract_addon_values
Filter the sanitized settings being written for an addon subtab before they are merged into perflocale_settings.
add_filter( 'perflocale/settings/extract_addon_values', function ( array $values, string $tab ): array {
if ( $tab !== 'my-addon' ) {
return $values;
}
$values['my_key'] = sanitize_text_field( wp_unslash( $_POST['my_key'] ?? '' ) );
return $values;
}, 10, 2 );Parameters: array $values, string $tab.
Args: 2
File: src/Admin/Pages/SettingsPage.php
GeoIP
perflocale/geo/visitor_ip
Override the visitor IP used for GeoIP lookups. Useful for testing on localhost or for honouring a specific proxy header.
add_filter( 'perflocale/geo/visitor_ip', function ( string $ip ): string {
return $_SERVER['HTTP_X_REAL_IP'] ?? $ip;
} );Parameters: string $ip.
File: src/Router/GeoRedirect.php
perflocale/geo/lookup_country
Short-circuit the GeoIP lookup with your own data source (e.g. a Cloudflare CF-IPCountry header).
add_filter( 'perflocale/geo/lookup_country', function ( string $country, string $ip ): string {
if ( ! empty( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) {
return strtoupper( (string) $_SERVER['HTTP_CF_IPCOUNTRY'] );
}
return $country;
}, 10, 2 );Parameters: string $country (ISO-3166-1 alpha-2, or empty), string $ip.
Args: 2
File: src/Router/GeoRedirect.php
Routing & detection
perflocale/accept_language_limit
Maximum number of ranked entries parsed out of the Accept-Language HTTP header when matching the visitor's preferred language.
add_filter( 'perflocale/accept_language_limit', fn() => 5 );Parameters: int $limit - Default 20.
File: src/Router/LanguageRouter.php
perflocale/query/child_post_types
Post types treated as children of their parent (e.g. WooCommerce variations). Query filtering follows the parent's language to avoid split listings.
add_filter( 'perflocale/query/child_post_types', function ( array $types ): array {
$types[] = 'my_variant_cpt';
return $types;
} );Parameters: array $types - Default [ 'product_variation' ].
File: src/Translation/PostQueryFilter.php
Language switcher (menu placement)
perflocale/switcher/menu_locations
Whitelist specific theme menu locations that should receive the language switcher.
add_filter( 'perflocale/switcher/menu_locations', function ( array $locations, \stdClass $args ): array {
return [ 'primary', 'header-utility' ];
}, 10, 2 );Parameters: 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. \stdClass $args - the current wp_nav_menu args.
Args: 2
File: src/Frontend/LanguageSwitcher.php
Machine translation
perflocale/mt/allowed_html
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.
add_filter( 'perflocale/mt/allowed_html', function ( array $allowed ): array {
$allowed['mark'] = [];
return $allowed;
} );Parameters: array $allowed.
File: src/MachineTranslation/TranslationService.php
Strings & content
perflocale/strings/regenerate_files
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.
add_action( 'perflocale/strings/regenerate_files', function ( \PerfLocale\Cache\CacheManager $cache ): void {
// Custom follow-up work.
} );Parameters: \PerfLocale\Cache\CacheManager $cache.
File: src/Cache/CacheInvalidator.php, src/Background/Jobs/BulkStringTranslateJob.php
perflocale/strings/changed
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).
add_action( 'perflocale/strings/changed', function ( string $origin ): void {
// $origin is 'admin_save', 'po_import', or 'bulk_mt'.
my_addon_invalidate_string_state( $origin );
} );Parameters: string $origin - what changed the strings (admin_save, po_import, or bulk_mt).
File: src/Admin/AdminController.php, src/Admin/PoSync.php, src/Background/Jobs/BulkStringTranslateJob.php
perflocale/translation/dangerous_meta_patterns
Meta-key fragments that must never be copied onto a translation - credentials, tokens and other secrets. Despite the name these are not regular expressions: every entry is tested with a case-insensitive substring match (stripos()) against the meta key, so _token also matches _my_plugin_TOKEN_v2. Wrapping a pattern in regex delimiters makes it match nothing. Defaults: _password, _secret, _token, _api_key, _apikey, stripe, paypal, _credentials, _encrypted, _auth. Applied to the post-meta copy performed when a translation is created.
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;
} );Parameters: array $patterns - key fragments, matched case-insensitively as substrings.
File: src/Translation/PostTranslationManager.php
Cache & SEO
perflocale/cache/flush_archive_hreflang
Fires when the archive hreflang cache is invalidated, after PerfLocale’s own wipe. Fires from two paths - a post whose status / language assignment changed, and a term edit (which also flushes the term’s translation siblings) - so the id is a post ID or a term ID. Full description: perflocale/cache/flush_archive_hreflang above.
add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $object_id ): void {
my_cdn_purge_archive_urls_for( $object_id );
} );Parameters: int $object_id — the post ID or term ID whose save / edit / delete triggered the flush. The hook does not distinguish the two.
File: src/Cache/CacheInvalidator.php
Admin & permissions
perflocale/abilities/enabled
Opt in to PerfLocale's WordPress Abilities API integration. 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, but they are off by default — they only register when this filter returns true. That keeps zero-overhead behaviour for installs that don't need them.
// Enable the integration. Required on WordPress 6.9+ for the abilities to appear.
add_filter( 'perflocale/abilities/enabled', '__return_true' );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— detect the language of a piece of text.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: bool $enabled - Default false.
File: src/Bootstrap.php
perflocale/block_toolbar/enabled
Toggle PerfLocale's block-editor toolbar button for flagging a block as non-translatable.
add_filter( 'perflocale/block_toolbar/enabled', '__return_false' );Parameters: bool $enabled - Default true.
File: src/Bootstrap.php
perflocale/menu/badge_post_limit
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.
add_filter( 'perflocale/menu/badge_post_limit', fn() => 10000 );Parameters: int $limit - Default 5000. 0 means unlimited (the LIMIT clause is dropped entirely - use carefully).
File: src/Translation/MenuManager.php
perflocale/migration/time_limit
Override the set_time_limit() value used during the TranslatePress migration import.
add_filter( 'perflocale/migration/time_limit', fn() => 900 );Parameters: int $seconds - Default 300.
File: src/Migration/TranslatePressImporter.php
perflocale/admin/bulk_time_limit
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.
// Cap the bulk admin handlers at 10 minutes on this host.
add_filter( 'perflocale/admin/bulk_time_limit', fn(): int => 600 );Parameters: int $seconds - Default 0 (no limit).
File: src/Bootstrap.php
Webhooks
perflocale/webhooks/url_safe
Final gate before a webhook URL is dispatched - return false to reject. The built-in checks already block private/loopback/metadata IPs; use this filter to layer org-specific policy on top.
add_filter( 'perflocale/webhooks/url_safe', function ( bool $safe, string $url ): bool {
$host = wp_parse_url( $url, PHP_URL_HOST );
return $safe && in_array( $host, [ 'hooks.my-org.example' ], true );
}, 10, 2 );Parameters: bool $safe, string $url.
Args: 2
File: src/Api/WebhookController.php