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

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/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/translatable_options

Filter the list of WordPress options that have per-language values.

Parameters: array $options - Array of option names.
File: src/Cache/CacheInvalidator.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

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 like featured_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. Any other value is treated as a meta key and synced via get_post_meta() / update_post_meta().

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

URL Handling

perflocale/router/bot_ua_pattern

Override the regex used to identify search-engine bots / crawlers. Bots that match are exempted from cookie-based language detection and from automatic redirect to the user’s preferred language - they always see the URL they crawled. Default pattern matches the case-insensitive, word-bounded tokens bot, crawl, spider, slurp, mediapartners, googlebot, bingbot, yandex, baidu — broad enough to cover the major engines and most generic crawlers. 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.
	return '/(MyMonitor|' . substr( $pattern, 2, -2 ) . ')/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

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

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

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_DEde-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

perflocale/indexnow/should_push

Veto an IndexNow push on a case-by-case basis. Use to skip preview/staging URLs, or to limit pushes to a rate-controlled window.

add_filter( 'perflocale/indexnow/should_push', function ( bool $should, array $urls, array $context ): bool {
	// Stop all pushes outside business hours.
	$hour = (int) wp_date( 'G' );
	return $hour >= 9 && $hour <= 17 ? $should : false;
}, 10, 3 );

Parameters: bool $should, array $urls, array $context (may contain trigger_post_id).
File: src/Seo/IndexNowPusher.php

perflocale/indexnow/urls

Modify the URL list before it’s sent. Add or remove URLs on the fly - useful for integrations with other plugins that know about URLs PerfLocale doesn’t.

add_filter( 'perflocale/indexnow/urls', function ( array $urls ): array {
	// Also ping the translated AMP versions.
	$amp = array_map( fn( $u ) => trailingslashit( $u ) . 'amp/', $urls );
	return array_merge( $urls, $amp );
} );

Parameters: array $urls, array $context.
File: src/Seo/IndexNowPusher.php

perflocale/indexnow/endpoint

Override the IndexNow endpoint per-host. Defaults to Cloudflare’s relay (https://api.indexnow.org/indexnow, which covers Google + Bing + Yandex) when the Cloudflare-relay setting is on, or direct Bing otherwise. You can force a different endpoint for specific hosts.

add_filter( 'perflocale/indexnow/endpoint', function ( string $endpoint, string $host ): string {
	// Send .cn hosts to Yandex directly.
	if ( str_ends_with( $host, '.cn' ) ) {
		return 'https://yandex.com/indexnow';
	}
	return $endpoint;
}, 10, 2 );

Parameters: string $endpoint, string $host, array $urls.
File: src/Seo/IndexNowPusher.php

perflocale/indexnow/key

Override the auto-generated 32-character hex key. The key is served by the plugin at {host}/{key}.txt for search-engine ownership verification - if you supply your own, make sure it’s also accessible at that URL. Rarely needed.

add_filter( 'perflocale/indexnow/key', fn() => defined( 'MY_INDEXNOW_KEY' ) ? MY_INDEXNOW_KEY : '' );

Parameters: string $key.
File: src/Seo/IndexNowPusher.php

Core filter - also honoured on WP < 6.8

The WordPress Core filter wp_speculation_rules_href_exclude_paths is automatically respected by PerfLocale on WP 6.8+ (Core applies it). On older WP (6.4–6.7), the fallback path also fires this filter so exclusions like /cart/* or /checkout/* work consistently across 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).
File: WordPress Core 6.8+  |  src/Frontend/SpeculationRulesEmitter.php (fallback path only).

Language Switcher

perflocale/switcher/languages

Filter the languages available in the switcher.

Parameters: array $languages - Array of language objects.
File: src/Frontend/LanguageSwitcher.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

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 - rtl when the target language's text_direction is RTL; omitted otherwise. Lets RTL labels render right-to-left even on an LTR page.
  • role, tabindex, aria-selected - Set only on dropdown options for listbox semantics.

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

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 → 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 $style is none or 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 with slug, 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 the showFlags / showNames / style settings).
  • 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/sanitize_output

Toggle whether kses_switcher() runs wp_kses() over the rendered HTML before echo. Default true (sanitised). Returning false skips the parse-and-rebuild pass entirely — useful on sites that fully trust their own filter callbacks and want to drop the per-render wp_kses cost.

// Skip wp_kses on this site — we trust every filter we register.
add_filter( 'perflocale/switcher/sanitize_output', '__return_false' );

// Skip wp_kses only on the frontend; keep it on for admin previews.
add_filter( 'perflocale/switcher/sanitize_output', function ( bool $sanitize ): bool {
	return is_admin() ? $sanitize : false;
} );

Trade-off: the renderer's per-attribute esc_url() / esc_attr() / esc_html() calls always run — they're what actually guarantee safety on baseline output. wp_kses is a defence-in-depth pass on top, useful when third-party filter callbacks (arrow_html, panel_before, panel_after, option_content, link_attrs) may not be fully trusted. Disable it when you own every callback and want maximum throughput on switcher-heavy pages.

Note: even when sanitize_output returns true, identical sanitised fragments are memoised per request by sha1( $html ), so repeated identical instances on the same page (header + footer switcher with the same languages + format) share one wp_kses call.

Parameters:

  • bool $sanitize - Whether to run wp_kses (default true).
  • string $html - The HTML being considered, for context-aware decisions.

Args: 2
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

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/String/StringTranslation.php

perflocale/strings/scanner/excluded_paths

Filter directory patterns excluded from string scanning.

Parameters: array $excluded - Array of directory patterns.
File: src/String/StringScanner.php

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. workflow_email_subject, email_subject_new_order).

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

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).

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 - the localhost,
// loopback, and private-IP gates always run for IP-literal URLs and
// CANNOT be bypassed by this filter.
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, not a security boundary. The localhost / loopback block and the IPv4/IPv6 private-range checks always run before anything that resolves a URL. Adding a host here only means "I trust DNS for this name to return a public IP" - it doesn't grant access to internal infrastructure. Hostnames returned by the filter are normalised (lowercased, non-strings dropped); a non-array return value falls back to the built-in list.

Default trusted hosts (built into PerfLocale): translation.googleapis.com, api.deepl.com, api-free.deepl.com, api.cognitive.microsofttranslator.com, libretranslate.com, plus api.openai.com and api.anthropic.com for AI-translator addons.

Parameters:

  • array<int, string> $hosts - Lowercase trusted hostnames. Add to or replace this array.

Args: 1
File: src/MachineTranslation/AbstractProvider.php

perflocale/tm/min_similarity

Translation memory's find_similar() ranks fuzzy matches by PHP's similar_text() percentage. By default it returns the top-N suggestions regardless of how low the similarity is. Use this filter to suppress suggestions below a confidence threshold so the editor UI only shows useful candidates.

// Only surface fuzzy suggestions that are at least 60% similar.
add_filter( 'perflocale/tm/min_similarity', fn() => 60.0 );

// Auto-apply territory: only use TM hits when they're 90%+ similar.
add_filter( 'perflocale/tm/min_similarity', fn() => 90.0 );

Common values:

  • 0 (default) — return everything that survived the SQL pre-filter (3-significant-words AND, length window).
  • 60 — "decent match" cut-off recommended for editor UI.
  • 75 — "high-confidence" threshold, suitable for auto-fill suggestions.
  • 90+ — near-exact only, suitable for unattended pipelines.

Parameters: float $threshold - Default 0.0 (no threshold).
File: src/Translation/TranslationMemory.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 beyond what the glossary covers.

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 glossary re-injection / kses sanitisation). 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/use_translation_memory

Per-request override of the Translation Memory toggle for block-level MT calls. Default is whatever the saved mt_use_translation_memory setting says. Return false to skip the TM lookup and force a fresh provider call.

// Skip TM for very short snippets so they always use fresh provider quality.
add_filter( 'perflocale/mt/use_translation_memory', function ( bool $use_tm, string $text, string $source_lang, string $target_lang ): bool {
	return mb_strlen( $text ) >= 16 && $use_tm;
}, 10, 4 );

Parameters: bool $use_tm, string $text, string $source_lang, string $target_lang.
File: src/Api/BlockTranslateController.php

WordPress 7.0+ Integrations (feature-detected — inert on earlier WordPress versions)

Hooks that wire PerfLocale into APIs shipping with WordPress 7.0 — the AI Client, the Connectors API, the JS-side Abilities API, and Block Hooks. Each surface is feature-detected; on a site running WordPress 6.x these filters fire but the 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/mt/quality_score_supported

Force-enable or force-disable the MT quality-scoring job's gate. Default auto-detects the AI Client and any custom resolver. Use this to enable scoring on a site running a non-WP-AI-Client AI gateway, or to disable it even when the AI Client is present.

// Enable scoring against a custom AI resolver.
add_filter( 'perflocale/mt/quality_score_supported', '__return_true' );

Parameters: bool $supported - Auto-detected support.
File: src/Background/Jobs/MtQualityScoreJob.php

perflocale/mt/quality_sample_size

Per-run sample size for the scoring cron. Default 50. Larger = faster coverage of the whole catalogue, smaller = lighter provider load and lower cost.

add_filter( 'perflocale/mt/quality_sample_size', fn() => 20 );

Parameters: int $sample_size.
File: src/Background/Jobs/MtQualityScoreJob.php

perflocale/mt/quality_score_tables

Restrict or extend the tables that the scoring sweep visits. Unknown table keys are skipped silently. Useful for cost-control (score strings only, not posts) or for adding a custom store mid-migration.

// Only score string translations, never post translations.
add_filter( 'perflocale/mt/quality_score_tables', fn() => [ 'string_translations' ] );

Parameters: string[] $tables, string $target (the target_table arg).
File: src/Background/Jobs/MtQualityScoreJob.php

perflocale/mt/quality_score_skip_row

Skip a specific row from scoring before any AI call is made. Useful for excluding legal disclaimers, low-traffic languages, RTL languages where you don't yet have a tuned model, or strings below a length threshold.

add_filter( 'perflocale/mt/quality_score_skip_row', function ( bool $skip, object $row, string $table ): bool {
	if ( isset( $row->translation ) && mb_strlen( (string) $row->translation ) < 40 ) {
		return true; // too short to be worth scoring
	}
	return $skip;
}, 10, 3 );

Parameters: bool $skip, object $row, string $table.
File: src/Background/Jobs/MtQualityScoreJob.php

perflocale/mt/quality_score_prompt

Tune the 1-5 scoring prompt for your domain. Legal-content sites can weight regulatory accuracy; marketing sites can weight brand voice. Keep the "output only a single digit" instruction or the response parser will mark the row as skipped.

add_filter( 'perflocale/mt/quality_score_prompt', function ( string $prompt, object $row, string $source, string $translation ): string {
	return "Rate this translation 1-5 with extra weight on preservation of regulated medical terminology.\n" .
	       "Source:\n$source\n\nTranslation:\n$translation\n\nOutput only 1, 2, 3, 4, or 5.";
}, 10, 4 );

Parameters: string $prompt, object $row, string $source_text, string $translated_text, string $source_slug, string $target_slug.
File: src/Background/Jobs/MtQualityScoreJob.php

perflocale/glossary/suggest_prompt

Override the prompt used by the Glossary admin form's suggest-as-you-type field (only invoked when the active MT provider is wp_ai_client). Keep the "return only the translated term" instruction or the cleanup pass will need to strip extra noise.

add_filter( 'perflocale/glossary/suggest_prompt', function ( string $prompt, string $term, string $source, string $target, array $prior ): string {
	return $prompt . "\n\nFollow our internal style guide: prefer formal register, never use anglicisms.";
}, 10, 5 );

Parameters: string $prompt, string $term, string $source_slug, string $target_slug, array $prior_examples.
File: src/Api/GlossaryScanController.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/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 (defaults cover the 4 MT providers only). Add geo provider keys, currency-rate keys, or any other plugin secret you'd like the Connectors API to broker.

add_filter( 'perflocale/connectors/slug_map', function ( array $map ): array {
	$map['geo_ipinfo_token']  = 'ipinfo';
	$map['geo_ipstack_key']   = 'ipstack';
	$map['wc_oxr_api_key']    = 'openexchangerates';
	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 (default false).
  • WP_Query $query - The query object.

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

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

WooCommerce

perflocale/woocommerce/exchange_rate_providers

Register or modify exchange rate API providers.

Parameters: array $providers - Provider definitions (see Exchange Rates docs).
File: src/WooCommerce/ExchangeRateSync.php

perflocale/woocommerce/exchange_rates_fetched

Filter rates after they are fetched from the API but before saving. Use to add a markup or override specific rates.

Parameters:

  • array $rates - Currency code => exchange rate.
  • string $base_currency - WooCommerce base currency code.
  • string $provider_id - Selected provider ID.

File: src/WooCommerce/ExchangeRateSync.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/skip_inventory_sync

Skip inventory sync for a specific product.

Parameters:

  • bool $skip - Default false.
  • 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

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

Filter available GeoIP providers. Add custom providers or modify built-in ones.

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 $map - Associative array of language slug => comma-separated country codes.
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 (0 when redirecting to the language homepage).

File: src/Translation/PostQueryFilter.php

Privacy

Gate for the automatic GeoIP + browser-language redirects. Return false to suppress both until the visitor has granted consent. Default true - redirects fire immediately, matching the pre-consent-framework behaviour.

// 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, 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 - default true.
  • WP_REST_Request $request - current request.

Args: 2
File: src/Api/LanguagesController.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/fse/translatable_blocks

Filter which block types are translatable in the Full Site Editor.

Parameters: array $blocks - Array of block type names.
File: src/Admin/SiteEditorIntegration.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 - PHP date() format string. Often locale-specific (e.g. 'j F Y' for European day-month-year).
  • time_format - PHP date() format string (e.g. 'g:i a', 'H:i').

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_manage_glossary, 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 three places: TranslatorRole::remove_roles(), and both the full-wipe and preserve-data branches of uninstall.php.

Parameters: string[] $roles - Array of role slugs. Default: ['administrator', 'editor'].
File: src/Admin/TranslatorRole.php, uninstall.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 ceiling

Parameters: 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: 100 for string_scan, 1000 for data_import / glossary_import, 500 for wpml_migration / polylang_migration, 200 for translatepress_migration, 5000 for data_export.

// 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 retry

Parameters: 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/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/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/import/batch_size

Rows committed per chunked-import REST call (POST /perflocale/v1/import/batch). Default 100. Higher values cut round-trip count but lengthen the per-batch transaction window and PHP memory peak. Clamped to 10–2000. The second arg is the table name so callers can pick different sizes per table.

add_filter( 'perflocale/import/batch_size', static function ( int $size, string $table ): int {
	return $table === 'strings' ? 500 : 100; // bigger batches for the big table
}, 10, 2 );

Parameters: int $size, string $table.
File: src/Api/ImportController.php

perflocale/import/max_file_bytes

Maximum size of the JSON envelope accepted by POST /perflocale/v1/import/upload. Default 52428800 (50 MB). Floor-clamped to 1 MB.

add_filter( 'perflocale/import/max_file_bytes', static fn(): int => 200 * 1024 * 1024 ); // 200 MB

Parameters: int $bytes.
File: src/Api/ImportController.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/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 MB

Parameters: int $bytes.
File: src/String/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. Callers that pass an explicit $batch_size to StringScanner::scan() override the filter; the filter is mostly useful when scanning is triggered via the StringScan background job which uses the default.

add_filter( 'perflocale/strings/scanner/batch_size', static fn(): int => 2000 );

Parameters: int $size.
File: src/String/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/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/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

perflocale/glossary/max_csv_bytes

Maximum size of a glossary CSV upload accepted by the admin importer. Default 100 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 — default true.
  • AbstractJob $job — the job instance about to dispatch (call $job->get_type() to branch by type).
  • array $args — the dispatch args (shape varies per job type).

Returns: bool|string. Return false to deny with the default error, or a non-empty string to deny with a custom error.
File: src/Background/Dispatcher.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, 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 }. requested is the user-chosen section list (skip your section if it's not requested); format_version is the envelope schema version (always 1 in 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 (the after_translate action still fires, with short_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 $prenull by default.
  • int $source_id — source post ID.
  • string $target_slug — target language slug (e.g. de, ar-EG).
  • array $context{ source_id, target_slug, target_id, provider, processed, total }. provider is the configured MT provider key (deepl / google / etc.). processed / total let you log progress.

Returns: mixed (see semantics above).
File: src/Background/Jobs/BulkTranslateJob.php

Actions

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/Admin/AdminController.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', 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/switched

Fires when the language is switched programmatically via switch_language().

Parameters:

  • string $slug - New language slug.
  • string $previous - Previous language slug.

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. enen-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/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

Workflow

perflocale/workflow/assigned

Fires when a translation is assigned to a user.

Parameters:

  • int $object_id - Post/term ID.
  • string $object_type - Object type.
  • int $language_id - Language ID.
  • int $user_id - Assigned user ID.

File: src/Admin/TranslatorRole.php

perflocale/workflow/status_changed

Fires when a workflow status changes.

add_action( 'perflocale/workflow/status_changed', function ( int $object_id, string $type, int $lang_id, string $status ): void {
	if ( $status === 'approved' ) {
		// Send Slack notification.
	}
}, 10, 4 );

Parameters:

  • int $object_id - Post/term ID.
  • string $object_type - Object type.
  • int $language_id - Language ID.
  • string $status - New workflow status.

File: src/Admin/TranslatorRole.php

perflocale/workflow/bulk_updated

Fires after the Assignments-page bulk-actions UI updates one column on a batch of workflow rows. Use this to mirror status / priority / assignee / deadline changes into external project-management tools.

add_action( 'perflocale/workflow/bulk_updated', function ( array $ids, string $column, $value ): void {
	if ( $column === 'status' && $value === 'approved' ) {
		// Notify Slack that N rows were just bulk-approved.
	}
}, 10, 3 );

Parameters:

  • array $ids - Workflow row IDs that were updated.
  • string $column - Column updated: one of status, priority, assigned_to, deadline.
  • mixed $value - Sanitised new value written to the column.

File: src/Admin/TranslatorRole.php

perflocale/workflow/bulk_deleted

Fires after the Assignments-page bulk Delete action removes rows.

Parameters:

  • array $ids - Workflow row IDs that were deleted.

File: src/Admin/TranslatorRole.php

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 external workflow systems.

Parameters:

  • array $source_ids - Source post IDs.
  • array $target_lang_ids - Target language IDs (single ID or all active languages).
  • int $count - Number of translation_links rows actually updated.

File: src/Admin/AdminController.php

IndexNow (1.0.0)

perflocale/indexnow/push_result

Fires after every IndexNow push attempt (per-host). Because wp_remote_post runs with blocking => false, $response reflects the dispatch result (usually a truncated WP_Error or an array with no body), not the final HTTP status. Use this to log pushes, track deliveries, or count daily volume against search-engine rate limits.

add_action( 'perflocale/indexnow/push_result', function ( $response, string $endpoint, string $host, array $urls, array $context ): void {
	error_log( sprintf(
		'[IndexNow] %s host=%s urls=%d trigger=%s',
		is_wp_error( $response ) ? 'FAIL: ' . $response->get_error_message() : 'OK',
		$host,
		count( $urls ),
		(string) ( $context['trigger_post_id'] ?? '' )
	) );
}, 10, 5 );

Parameters: WP_Error|array $response, string $endpoint, string $host, array $urls, array $context.
File: src/Seo/IndexNowPusher.php

Machine Translation

perflocale/mt/quality_score_persist

Fires after the MT quality-scoring job persists a 1-5 score to a row. Hook this to mirror scores into external observability (Datadog, ELK, Slack), auto-assign low scorers to a senior translator through your workflow integration, or trigger ad-hoc notifications.

add_action( 'perflocale/mt/quality_score_persist', function ( int $score, object $row, string $table ): void {
	if ( $score <= 2 ) {
		// Ping our review channel.
		MyNotifier::low_score( $score, $row, $table );
	}
}, 10, 3 );

Parameters:

  • int $score - Score that was just stored (1-5).
  • object $row - Row that was scored. Shape varies by $table.
  • string $table - 'string_translations' or 'translation_links'.

File: src/Background/Jobs/MtQualityScoreJob.php

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 - Post ID.
  • string $provider_id - Provider identifier.
  • mixed $result - Translation result.

File: src/MachineTranslation/TranslationService.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

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_scheduler or wp_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 (worker's return value, already stored on the job row, truncated to 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. Also fires when the export ran inline (below the threshold) and was streamed directly — in that case $path is the inline-streamed temp file and may be unlinked immediately after this action returns.

// 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 inside uploads/perflocale-exports/).
  • int $bytes — size of the written file.
  • array<int,string> $sections — the section keys that were exported (settings, glossary, translations, etc.).

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 a post-download workflow (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 }. replace mirrors the import's replace-mode flag; truncate your tables when it's true. file_path is 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, IndexNow pushes, 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, workflow 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 the before_translate context plus two extra keys:
    • short_circuited: bool — true if the row was handled by a before_translate filter callback.
    • error: string — the exception message or fallback error text; empty string on success.

File: src/Background/Jobs/BulkTranslateJob.php

Addons

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

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.
  • 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

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 - Default true.
  • string $slug - Candidate language slug from header/cookie.

File: src/Router/LanguageRouter.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 full cache layer (e.g. on language add/remove or admin "Clear cache" button). 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_archive_hreflang (action)

Fires when an archive’s hreflang cache is invalidated for a given post (typically after the post’s status / language assignment changes).

add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $post_id ): void {
	my_cdn_purge_tags( [ 'archive-hreflang:' . $post_id ] );
} );

Parameters: int $post_id.
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

Workflow Email

perflocale/workflow/email

Filter the outgoing workflow email before wp_mail() dispatches it. Useful for routing admin mail through a transactional provider, rewriting recipients, or appending localized footers.

add_filter( 'perflocale/workflow/email', function ( array $mail, WP_User $user ): array {
	$mail['headers'][] = 'X-Transactional: workflow';
	$mail['body'] .= "\n\n" . __( 'This is an automated notification.', 'my-theme' );
	return $mail;
}, 10, 2 );

Parameters:

  • array $mail - { to, subject, body, vars }. vars is the rendered placeholder map: {post_title}, {assignee_name}, {language}, {status}, {priority}, {deadline}, {deadline_relative}, {edit_url}, {site_name}. See the Workflow placeholder reference for descriptions.
  • WP_User $user - Recipient.

File: src/Admin/WorkflowNotifier.php

perflocale/workflow/bulk_reassign_digest_body

Filter the plaintext body of the digest email sent to a user when the Assignments-page bulk Reassign action routes a batch of assignments to them. One email per recipient is dispatched (not N), and this filter sees the assembled summary before wp_mail() ships it.

add_filter( 'perflocale/workflow/bulk_reassign_digest_body', function ( string $body, int $user_id, array $rows ): string {
	// Append a CTA + signature.
	$body .= "\n— Translation team\n";
	return $body;
}, 10, 3 );

Parameters:

  • string $body - Plaintext email body, including the “N new assignments” intro and one bullet per (post, language) pair with priority + deadline annotations.
  • int $user_id - Recipient user ID (the new assignee).
  • array $rows - Workflow rows being reassigned, captured BEFORE the bulk update so the body can mention prior state if needed.

File: src/Admin/AdminController.php

Rate Limits & Scan Caps

perflocale/mt/rate_limit

Per-user hourly ceiling on POST /perflocale/v1/machine-translate calls. Return 0 to disable rate limiting entirely.

add_filter( 'perflocale/mt/rate_limit', fn() => 1000 );

Parameters: int $limit - Default 500 requests per hour.
File: src/Api/MachineTranslateController.php

perflocale/glossary/scan_limit

Maximum posts scanned per invocation of the auto-glossary candidate scanner. Clamped to [10, 5000].

add_filter( 'perflocale/glossary/scan_limit', fn() => 2000 );

Parameters: int $limit - Default 500.
File: src/Translation/GlossaryScanner.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/addons/suppress_hreflang

Let an SEO-plugin addon suppress PerfLocale's own hreflang output when it already emits the tags. Returns a boolean.

add_filter( 'perflocale/addons/suppress_hreflang', function ( bool $suppress ): bool {
	return true; // Let the host SEO plugin own hreflang entirely.
} );

Parameters: bool $suppress - Default false.
File: src/Addon/HreflangCoordinator.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 'postmeta' | 'termmeta' | 'usermeta' etc.
}, 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/add_to_menu

Return true from within wp_nav_menu_items filtering to have the language switcher appended to a nav menu without an explicit menu location.

add_filter( 'perflocale/switcher/add_to_menu', function ( bool $add, \stdClass $args ): bool {
	return $args->theme_location === 'primary';
}, 10, 2 );

Parameters: bool $add, \stdClass $args.
Args: 2
File: src/Frontend/LanguageSwitcher.php

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, \stdClass $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. Tighten or loosen to taste.

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 after the cache invalidator decides a .mo regeneration is warranted. 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

perflocale/translation/dangerous_meta_patterns

Regex list for meta keys that must never be translated or synced (serialised PHP objects, private flags, etc.). Patterns are matched against the meta key.

add_filter( 'perflocale/translation/dangerous_meta_patterns', function ( array $patterns ): array {
	$patterns[] = '/^_my_encrypted_/';
	return $patterns;
} );

Parameters: array $patterns.
File: src/Translation/PostTranslationManager.php

Cache & SEO

perflocale/cache/flush_archive_hreflang

Fires when the archive-page hreflang cache is invalidated for a post. Use it to purge an external CDN cache for the same URL group.

add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $post_id ): void {
	my_cdn_purge_archive_urls_for( $post_id );
} );

Parameters: int $post_id.
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 posts scanned when computing the "pending translations" badge on the admin menu. Keeps badge counts cheap on very large sites.

add_filter( 'perflocale/menu/badge_post_limit', fn() => 10000 );

Parameters: int $limit - Default 5000.
File: src/Translation/MenuManager.php

perflocale/migration/time_limit

Override the set_time_limit() value used during admin-triggered migration imports.

add_filter( 'perflocale/migration/time_limit', fn() => 900 );

Parameters: int $seconds - Default 300.
File: src/Admin/AdminController.php

perflocale/admin/bulk_time_limit

Override the set_time_limit() value used during the two bulk admin AJAX handlers: Create WooCommerce page translations and Create taxonomy translations. Both 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