Hooks Reference

Developer reference for all apply_filters and do_action hooks in PerfLocale: 309 hooks, 236 filters and 73 actions. Each card shows the hook’s parameters, an example and its source file.

Filters

Settings & Configuration 18

Filter which non-public post types are offered in the Advanced content types group at Settings → Translation.

Defaults to [ 'wp_block', 'wp_navigation', 'wp_template', 'wp_template_part' ] - synced patterns, block-theme navigation menus, and block templates and template parts.

This is a curated allowlist, not get_post_types( [ 'public' => false ] ): a checkbox is only offered for a type PerfLocale can actually serve a translation for. wp_template and wp_template_part are in the default list but are not shown as individual checkboxes - they are switched on together by the separate Full Site Editing setting, so removing them here puts them beyond that setting's reach too. See Full Site Editing.

Note: this list controls the settings UI only. It is separate from perflocale/translatable_post_types, which also governs language filtering of ordinary content queries.

Parameters
ParameterDescription
array $typesPost type names.
Returns
array
Example4 lines
add_filter( 'perflocale/settings/non_public_post_types', function ( array $types ): array {
	$types[] = 'my_private_type';
	return $types;
} );

Source src/Admin/Pages/SettingsPage.php(opens GitHub in a new tab)

Filter the human-readable label shown for a string’s context on the Strings screen.

A context is an identifier meant for code (blogname, attribute_label_size), but it is shown to an operator, so a domain that knows a friendlier name can supply one.

PerfLocale uses this itself to show Site Title and Tagline instead of blogname and blogdescription. Always scope your callback to your own domain, or you will relabel another plugin’s identically-named context.

Parameters
ParameterDescription
string $labelContext, unchanged by default.
string $domainThe string’s text domain.
object $stringThe string row.
Returns
string
Example6 lines
add_filter( 'perflocale/strings/context_label', function ( string $label, string $domain, $string ): string {
	if ( 'my-plugin' === $domain && 'hdr' === $label ) {
		return __( 'Header text', 'my-plugin' );
	}
	return $label;
}, 10, 3 );

Source src/Admin/Pages/StringsPage.php(opens GitHub in a new tab)

Filter the admin URL PerfLocale links to when it offers to edit an object.

Use it when a post type’s editor is not WordPress’s own - a form builder, a page builder - or return an empty string to render no link at all.

Why this exists: get_edit_post_link() builds from the post type’s _edit_link, which WordPress only populates for types registered with show_ui. Contact Form 7 forms, WPForms forms and WooCommerce variations are all registered without it, so core returns null for exactly the objects a translation screen most needs to link.

Contract - read this before shipping a callback:

  • Return a string. An absolute http(s) URL with a host, or ''. A Stringable object is accepted and converted. Anything else - array, object, null, false - is discarded and treated as ''.
  • Relative URLs are rejected. A relative href resolves against whatever admin page the user is on, so it looks like a working link and goes nowhere. '' is always the safer answer.
  • IDs are current-blog. A bare integer has no network-wide identity; on multisite, switch_to_blog() before resolving.
  • Pass through what you do not own. Check $post_type first and return $url unchanged otherwise. Later callbacks see your return value, and returning a non-string can raise a TypeError inside another integration’s typed callback.
  • Exceptions are not contained. A callback that throws, or a Stringable whose conversion throws, propagates as WordPress normally would. Only the final value is validated.
  • This resolves a URL. It does not authorize anything. PerfLocale still applies its own capability checks, and the value is escaped at every HTML sink. A non-empty URL is not permission.
Parameters
ParameterDescription
string $urlURL resolved so far, '' when core could not.
int $post_idObject being linked.
string $post_typeIts post type.
Returns
string
Example7 lines
add_filter( 'perflocale/admin/edit_post_link', function ( string $url, int $post_id, string $post_type ): string {
	if ( 'my_form' !== $post_type ) {
		return $url; // Always pass through what you do not own.
	}

	return admin_url( 'admin.php?page=my-builder&form=' . $post_id );
}, 10, 3 );

Source src/Admin/ObjectLinks.php(opens GitHub in a new tab)

Filter the front-end URL PerfLocale offers as “View”.

Use it for a post type that is not publicly_queryable but is still reachable.

Why this exists: get_permalink() returns a URL for a non-viewable post type too - one that 404s - so an emptiness check cannot tell a live link from a dead one. PerfLocale gates on is_post_type_viewable() instead. That is correct for form post types and wrong for a WooCommerce variation, whose permalink resolves to its parent product and returns HTTP 200; the bundled WooCommerce integration restores it through this filter.

The same return contract applies as above: an absolute http(s) URL with a host, or ''.

Parameters
ParameterDescription
string $urlURL resolved so far, '' when the type is not viewable.
WP_Post $postObject being linked.
Returns
string
Example7 lines
add_filter( 'perflocale/admin/view_post_link', function ( string $url, WP_Post $post ): string {
	if ( 'my_type' !== $post->post_type ) {
		return $url;
	}

	return get_permalink( $post->post_parent );
}, 10, 2 );

Source src/Admin/ObjectLinks.php(opens GitHub in a new tab)

Filter URL paths excluded from language prefix injection.

Parameters
ParameterDescription
array $pathsArray of path prefixes (e.g., '/wp-json/').
Returns
array
Example5 lines
add_filter( 'perflocale/excluded_paths', function ( array $paths ): array {
	$paths[] = '/my-api/'; // Custom REST endpoint.
	$paths[] = '/sitemap.xml'; // Sitemap file.
	return $paths;
} );

Source src/Settings.php(opens GitHub in a new tab)

Text domains whose strings the daily stale-string garbage collector must never delete.

Backed by the persistent perflocale_gc_protected_domains option, so an addon's protection survives its own deactivation.

Parameters
ParameterDescription
string[] $domainsProtected text domains.
Returns
array
Example4 lines
add_filter( 'perflocale/strings/protected_domains', function ( array $domains ): array {
	$domains[] = 'my-plugin'; // Never GC my plugin's registered strings.
	return $domains;
} );

Source src/Database/Repository/StringRepository.php(opens GitHub in a new tab)

Rows-per-language at which the Site Health check warns that database string mode is loading a large per-request blob.

Default 20,000.

Parameters
ParameterDescription
int $thresholdRows per language (default 20000).
Returns
int
Example3 lines
add_filter( 'perflocale/site_health/string_blob_threshold', function ( int $threshold ): int {
	return 40000; // Warn later on a memory-rich host.
} );

Source src/Admin/SiteHealth.php(opens GitHub in a new tab)

Whether Site Health actively tests that the export directory is not web-readable.

The check writes a short-lived random file into wp-content/uploads/perflocale/exports/, requests it over HTTP, and deletes it either way; the verdict is cached for an hour, and an inconclusive result for fifteen minutes.

Return false on a host where loopback requests are blocked and transients do not persist - the combination where the probe would repeat its timeout on every Site Health load without ever learning anything. Disabling it skips only the measurement: the check still appears and still tells you to add the server rule, because a filter should never be able to make a real exposure look green. If you turn this off, verify the rule yourself - see Security.

Parameters
ParameterDescription
bool $probeWhether to write the canary and make the request (default true).
Returns
bool
Example1 line
add_filter( 'perflocale/site_health/probe_export_exposure', '__return_false' );

Source src/Admin/SiteHealth.php(opens GitHub in a new tab)

Whether to append the language switcher to a classic menu.

Defaults to true when any menu location is selected on the Language Switcher settings tab; a filter can force it per menu.

Parameters
ParameterDescription
bool $add-
stdClass $args(wp_nav_menu args).
Returns
bool
Example3 lines
add_filter( 'perflocale/switcher/add_to_menu', function ( bool $add, $args ): bool {
	return $args->theme_location === 'primary'; // Only the primary menu.
}, 10, 2 );

Source src/Frontend/LanguageSwitcher.php(opens GitHub in a new tab)

Filter the list of active languages returned by the router.

Parameters
ParameterDescription
array $languagesArray of language objects.
Returns
array
Example7 lines
// 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;
} );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Filter the date format PerfLocale resolves for a language: from perflocale()->date_format(), and on the front end when it overrides WordPress's date_format option (that override applies only when the language has its own date format set).

Default is the language row’s date_format field if set, else the WP date_format option. Lets a theme/addon override per-language without saving to the language row.

Parameters
ParameterDescription
string $format-
object|null $lang-
Returns
string
Example6 lines
add_filter( 'perflocale/date_format', function ( string $format, ?object $lang ): string {
	if ( $lang && $lang->slug === 'ja' ) {
		return 'Y年n月j日'; // Japanese long date.
	}
	return $format;
}, 10, 2 );

Source src/Helper.php(opens GitHub in a new tab) src/Frontend/LocaleDateFormat.php(opens GitHub in a new tab)

The whole per-language fallback map, before it is validated.

The $context argument says what is being asked - so the same site can fall back differently for, say, front-end rendering than for hreflang output. Whatever the filter returns is put back through the same guardrails as the stored setting (unknown slugs dropped, self-references removed, length capped), so a malformed return cannot corrupt resolution.

Parameters
ParameterDescription
array<string, array> $mapLanguage slug => ordered fallback slugs.
string $contextWhat the chain is being resolved for (empty string when unspecified).
Returns
array
Example4 lines
add_filter( 'perflocale/language/fallbacks', function ( array $map, string $context ): array {
	$map['de-at'] = [ 'de' ]; // Austrian German falls back to German.
	return $map;
}, 10, 2 );

Source src/Settings.php(opens GitHub in a new tab)

The resolved fallback chain for ONE language, after the map has been read.

Use this when the decision depends on the language being resolved rather than on the whole map. As with the map filter, the returned chain is re-validated, so guardrails still apply.

Parameters
ParameterDescription
array<int, string> $chainOrdered fallback language slugs.
string $slugLanguage the chain belongs to.
string $contextWhat the chain is being resolved for.
Returns
array
Example6 lines
add_filter( 'perflocale/language/fallback_chain', function ( array $chain, string $slug, string $context ): array {
	if ( 'hreflang' === $context ) {
		return []; // Never advertise a fallback as an alternate.
	}
	return $chain;
}, 10, 3 );

Source src/Settings.php(opens GitHub in a new tab)

Translation Creation 4

Filter the new post data before a translation is created via wp_insert_post().

Parameters
ParameterDescription
array $dataPost data array (post_type, post_status, post_title, etc.).
int $source_idSource post ID.
string $target_slugTarget language slug.
Returns
array
Example4 lines
add_filter( 'perflocale/translation/post_data', function ( array $data, int $source_id, string $target_slug ): array {
	$data['post_author'] = get_current_user_id(); // Set current user as author.
	return $data;
}, 10, 3 );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab)

Filter the meta keys excluded from being copied when creating a translation.

The bundled ACF, Meta Box and Pods add-ons use this filter to keep the values of the source’s password fields out of a new translation: ACF password fields at any depth (inside groups, repeaters, flexible content and clones), Meta Box password fields stored as post meta of their own, and Pods password fields. A Meta Box group is one meta row holding all its sub-fields, so the Meta Box add-on also listens to perflocale/translation/created and rewrites each copied group row without its password sub-fields, in nested groups and every clone too; the other values of the row stay as copied. When content sync later seeds a group into a translation that has no row for it, the add-on takes the same sub-fields out through perflocale/sync/seed_meta_value.

Parameters
ParameterDescription
array $keysMeta key names to exclude.
int $source_idSource post ID.
Returns
array
Example4 lines
add_filter( 'perflocale/translation/excluded_meta_keys', function ( array $keys, int $source_id ): array {
	$keys[] = '_my_private_meta'; // Don't copy this to translations.
	return $keys;
}, 10, 2 );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab)

Lock TTL (seconds) for the create_translation() critical section that serialises concurrent translation creation for the SAME (source_post, target_language) pair.

The default of 60s covers most real-world inserts including a meta + featured-image copy step that runs synchronously. Sites with slow save_post integrations (heavy SEO plugins, image regeneration on insert) can extend this so a legitimately-long insert doesn't lose its lock mid-flight.

Parameters
ParameterDescription
int $ttlLock TTL in seconds. Default 60. Floor 5.
Returns
int
Example2 lines
// Bump to 5 minutes if your save_post chain is heavy (image regen, SEO, ACF Field Sync).
add_filter( 'perflocale/translation/create_lock_ttl', static fn(): int => 300 );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab)

Content Sync 7

Filter the fields synchronized across translations when a post is saved.

Built-in field keys: featured_image, menu_order, post_parent, post_date, post_author, comment_status, ping_status. Each of these is compared against the translation's current value first, and the translation is written only if something actually differs - so a save that changes nothing synced fires no save_post for the translations and leaves their modified dates alone. When a translation is written, only the synced columns and its modified date change: its content, title, excerpt, status and publish date (unless post_date is synced) stay as stored, whoever saves, and its save hooks run as for any save. When a synced post_parent moves a translation to another parent, WordPress makes its slug unique among the new parent’s children (for example by adding -2); any other write keeps the stored slug. The perflocale/cache/flush_object purge signal still fires for each translation. Any other value is treated as a meta key: rows are read with get_post_meta() and written row by row with delete_post_meta() + add_post_meta(), so multi-value meta survives.

Parameters
ParameterDescription
array $fieldsField names (built-in keys like featured_image, menu_order, or custom meta keys).
string $post_typeThe post type being saved.
Returns
array
Example10 lines
// 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 );

Source src/Translation/ContentSync.php(opens GitHub in a new tab)

Runs immediately after perflocale/sync_fields and expands per-post dynamic meta keys that the post-type-scoped list can’t enumerate - e.g. ACF repeater / flexible-content rows, whose key set depends on each post’s actual row count (the static field list only knows row 0).

The callback receives the SOURCE post id so it can read the real row structure and append the remaining rows’ keys. PerfLocale’s bundled ACF addon uses this hook.

Parameters
ParameterDescription
array $sync_fieldsmeta keys to sync;
int $post_idsource post id.
Returns
array
Example8 lines
add_filter( 'perflocale/sync_fields/for_post', function ( array $fields, int $post_id ): array {
	// Row 0 is already registered by the static field list, so add rows 1+.
	$rows = (int) get_post_meta( $post_id, 'my_repeater', true );
	for ( $i = 1; $i < $rows; $i++ ) {
		$fields[] = "my_repeater_{$i}_subfield";
	}
	return $fields;
}, 10, 2 );

Source src/Translation/ContentSync.php(opens GitHub in a new tab)

Filter the user whose rights gate the Sync Fields of a scheduled post that WordPress publishes without a person.

That is a publish in a cron run, or outside WP-CLI in a request of nobody logged in or of a user who may not edit the post. The publish then writes Sync Fields, and a changed post password, only to the translations this user may edit, as a save by that user would; 0 writes them to every translation. The default is the post author when the author may edit the post, otherwise 0. See Scheduled posts in the Sync Fields docs.

Runs once per such publish that syncs Sync Fields or copies a changed password: not for a post without translations or opted out of sync, and for Sync Fields not for a translation that Sync Fields marked Needs Update. A return that is not an integer of 0 or more leaves the default.

Parameters
ParameterDescription
int $user_idUser whose rights gate the publish, 0 for none. Default: the post author when the author may edit the post, otherwise 0.
int $post_idPost being published.
Returns
int
Example2 lines
// A scheduled publish updates every translation, whoever wrote the post.
add_filter( 'perflocale/sync/scheduled_publish_user', '__return_zero' );

Source src/Translation/ContentSync.php(opens GitHub in a new tab)

Veto the term-hierarchy sync for one translation sibling.

When a translatable term is edited, PerfLocale mirrors its parent onto every linked sibling - mapped into the sibling’s own language, so a German child points at the German parent rather than at the English parent’s term_id (which would orphan it cross-language). Name, slug, and description stay translator-owned and are never synced.

Return false to leave one sibling’s hierarchy alone while the rest keep mirroring - useful for a market that runs a deliberately flatter catalogue. The filter is only reached when the Sync Term Hierarchy setting (sync_term_hierarchy, default on) is enabled: that setting is the all-or-nothing switch, this filter is the selective one. It is also skipped when the edited term’s parent has no translation in the sibling’s language - PerfLocale leaves the sibling untouched rather than guess a cross-language parent.

Parameters
ParameterDescription
bool $synctrue by default (mirror the hierarchy).
WP_Term $source_termthe term that was edited.
int $sibling_idthe translation sibling term about to be updated.
string $taxonomytaxonomy slug.
int $translated_parentthe source term’s parent mapped into the sibling’s language (0 = top level).
Returns
bool.
Example9 lines
// Let editors pin individual categories out of the hierarchy mirror.
add_filter( 'perflocale/sync/term_parent', function ( bool $sync, WP_Term $source_term, int $sibling_id, string $taxonomy, int $translated_parent ): bool {
	if ( $taxonomy !== 'product_cat' ) {
		return $sync;
	}

	// A sibling an editor flagged keeps whatever parent it has now.
	return get_term_meta( $sibling_id, '_manual_hierarchy', true ) === 'yes' ? false : $sync;
}, 10, 5 );

Source src/Translation/ContentSync.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
array $keys-
string $post_type(may be empty for "any post type").
Returns
array
Example7 lines
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 );

Source src/Settings.php(opens GitHub in a new tab)

Whether the per-language Featured Image metabox is registered on the post-edit screen for a given post type.

Default true. Returning false suppresses the metabox without disabling the frontend featured-image swap (existing per-language overrides keep working) - useful for themes or addons that render their own featured-image surface and want to host the language overrides inside that UI instead. Only consulted for post types that support thumbnails and when the active theme declares post-thumbnails support.

Parameters
ParameterDescription
bool $showtrue by default.
string $post_typethe post type being evaluated.
Returns
bool.
Example4 lines
// Hide PerfLocale's metabox for products - a custom UI hosts the overrides.
add_filter( 'perflocale/media/show_featured_image_panel', static function ( bool $show, string $post_type ): bool {
	return $post_type === 'product' ? false : $show;
}, 10, 2 );

Source src/Translation/MediaTranslationManager.php(opens GitHub in a new tab)

Which languages the per-language Featured Image panel renders for the post being edited.

Defaults to PerfLocale’s already-filtered active-language list. Lets a theme or addon drop languages (for example hide RTL languages when the theme ships no RTL stylesheet) or, by returning the full unfiltered list, show every active language regardless of the default filtering.

Parameters
ParameterDescription
array<int, object> $languagesthe languages PerfLocale would render.
int $post_idthe post being edited.
array<int, object> $all_languagesthe unfiltered list of every active language.
Returns
array of language objects.
Example4 lines
// Only offer the site's two primary languages in the Featured Image panel.
add_filter( 'perflocale/media/featured_image_panel_languages', function ( array $languages, int $post_id, array $all_languages ): array {
	return array_filter( $languages, static fn( $lang ) => in_array( $lang->slug, [ 'en', 'de' ], true ) );
}, 10, 3 );

Source src/Translation/MediaTranslationManager.php(opens GitHub in a new tab)

URL Handling 6

Override the regex used to identify search-engine bots / crawlers.

Bots that match are exempted from the three automatic redirect paths - browser-language, edge-hint, and geo-IP - so they always see the URL they crawled. (Cookie-based detection is unaffected: those redirects already stand down for any visitor who has a perflocale_lang cookie.) The default pattern matches these case-insensitive, word-bounded tokens: bot, crawl, spider, slurp, mediapartners, googlebot, bingbot, yandex, baidu, twitterbot, applebot, facebookexternalhit, linkedinbot, duckduckbot, gptbot, slackbot, discordbot, telegrambot, petalbot, ahrefsbot, semrushbot, pinterestbot, oai-searchbot, chatgpt-user, claudebot, claude-web, claude-user, claude-searchbot, anthropic-ai, perplexitybot, perplexity-user, ccbot, bytespider, amazonbot, meta-externalagent, meta-externalfetcher, duckassistbot, mistralai-user, cohere-ai, diffbot - classic search and social crawlers plus the AI crawlers and assistant fetchers (OpenAI, Anthropic, Perplexity, Common Crawl, ByteDance, Amazon, Meta, DuckDuckGo, Mistral, Cohere, Diffbot), which index for AI search and answer engines and mis-index a language redirect exactly like a classic engine. Compound names are enumerated explicitly because \bbot\b sees a letter-letter boundary inside “googlebot”. Return an empty string to disable bot exemption entirely.

Parameters
ParameterDescription
string $patternCompiled regex (with delimiters and flags).
Returns
string
Example6 lines
add_filter( 'perflocale/router/bot_ua_pattern', function ( string $pattern ): string {
	// Add a custom monitoring crawler to the bypass list.
	// substr( $pattern, 3, -4 ) strips the leading '/\b' and the trailing '\b/i',
	// leaving just the '(token|token|...)' group.
	return '/\b(MyMonitor|' . substr( $pattern, 3, -4 ) . ')\b/i';
} );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Filter a URL after it has been converted to a target language.

Parameters
ParameterDescription
string $urlThe converted URL.
string $target_slugTarget language slug.
string $current_slugCurrent language slug.
Returns
string
Example4 lines
add_filter( 'perflocale/url/convert', function ( string $url, string $target_slug, string $current_slug ): string {
	// Custom URL transformation.
	return $url;
}, 10, 3 );

Source src/Router/UrlConverter.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
bool $enableddefault true.
Returns
bool
Example1 line
add_filter( 'perflocale/redirect_default_to_prefix', '__return_false' );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

The mirror of perflocale/redirect_default_to_prefix.

When “Hide URL prefix for default language” IS enabled, / is canonical, but the still-prefixed default-language home (e.g. /en/) also resolves to the same homepage - a duplicate-content URL. The plugin 301-redirects that bare /en/ back to the canonical /. This redirect is home-only: inner prefixed URLs such as /en/contact/ are left untouched (their per-page canonical tag already de-duplicates them). Return false to disable it and let both / and /en/ resolve. GET/HEAD requests only.

Parameters
ParameterDescription
bool $enableddefault true.
Returns
bool
Example1 line
add_filter( 'perflocale/redirect_prefix_to_default', '__return_false' );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Override the URL mode in code (e.g. per environment via wp-config).

One of subdirectory, subdomain, domain, or query - the last being the ?lang= query-parameter mode that works with every permalink structure (including Plain) on any server; the default language always keeps clean, param-free URLs. MUST return the same value for every call within a site - every generated link, rewrite rule, and cached URL assumes one mode per site. Vary it per environment or per blog, never per request. Unknown values fall back to the stored setting.

Parameters
ParameterDescription
string $modeone of subdirectory, subdomain, domain, query.
Returns
string
Example2 lines
// Force query-parameter mode on a staging box that can't rewrite.
add_filter( 'perflocale/url_mode', fn() => 'query' );

Source src/Settings.php(opens GitHub in a new tab)

Rename the query variable used by query URL mode.

The default is lang, which is a generic name another plugin, theme, or page builder may already own - leftover Polylang installs are the common case. Renaming it moves PerfLocale out of the way without touching rewrite rules by hand: the URL writer, the language detector, and the generated rewrite rules all read this one value, so they cannot disagree.

The returned value is passed through sanitize_key(), and an empty or non-string return falls back to lang, so the result is always a safe query-argument name. The filter fires once per request (once per blog on multisite) no matter how many URLs the page builds, so it is not on any hot path.

Register this from an mu-plugin. The name is resolved while PerfLocale itself loads, because query-mode language detection has to read the request before plugins_loaded fires. A filter added from an ordinary plugin or from a theme’s functions.php runs too late and is ignored - silently, since there is nothing to warn about at that point. Files in wp-content/mu-plugins/ load first, so that is the supported home for it.

Renaming this is a URL change, not a cosmetic one. Existing links carrying the old name stop resolving, and in subdirectory mode the name is baked into stored rewrite rules - flush permalinks (Settings › Permalinks) after adding or changing the filter. Return the same value for every call within a site.

Parameters
ParameterDescription
string $query_vardefault lang.
Returns
string
Example3 lines
// wp-content/mu-plugins/my-lang-var.php
// Leave `lang` to another plugin.
add_filter( 'perflocale/url/query_var', fn() => 'pfl_lang' );

Source src/Router/UrlConverter.php(opens GitHub in a new tab)

SEO 6

Filter the full hreflang tag array (including the x-default entry) right before it’s emitted in <head> and on the Link HTTP header.

Use it to add, remove, reorder, or mutate entries.

Dev-mode guard: if a filter callback returns a non-array, PerfLocale emits a _doing_it_wrong() notice and falls back to the unfiltered tag list - hreflang output is preserved.

Parameters
ParameterDescription
array $tagsarray of ['hreflang' => string, 'href' => string], computed per-request and cached.
Returns
array
Example10 lines
// 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;
} );

Source src/Frontend/HreflangTags.php(opens GitHub in a new tab)

Cache lifetime (in seconds) for the archive / paged hreflang block.

Each ?paged=N archive request caches its own computed hreflang HTML; the default 12 * HOUR_IN_SECONDS balances rebuild cost against reflecting a translation change within half a day if a narrow per-post flush is missed. Singular posts are kept fresh by the per-post invalidator regardless of this value.

Parameters
ParameterDescription
int $ttlcache lifetime in seconds (default 12 * HOUR_IN_SECONDS).
Returns
int
Example2 lines
// Shorten archive hreflang caching to one hour.
add_filter( 'perflocale/seo/hreflang_cache_ttl', fn() => HOUR_IN_SECONDS );

Source src/Frontend/HreflangTags.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string $default_urlURL currently slated for x-default (PerfLocale’s computed value).
object $defaultdefault-language object (slug, locale, name, etc.).
string $current_slugslug of the language currently being rendered.
Returns
string
Example7 lines
// 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;
} );

Source src/Frontend/HreflangTags.php(opens GitHub in a new tab)

Override the Include Fallback Languages setting programmatically.

When the filter returns true, hreflang emits a tag for every active language - even those without an explicit translation of the current post - as long as the language URL would render 200 (i.e. missing_translation_action is show_default or the language has a non-empty fallback chain). Filter value wins over the admin toggle, so addon or theme code can force the inclusive mode without touching settings.

Parameters
ParameterDescription
bool $enabledcurrent setting value (defaults to false).
Returns
bool
Example10 lines
// Always advertise every active language, regardless of admin setting.
add_filter( 'perflocale/seo/hreflang_include_fallbacks', '__return_true' );

// Turn on only for singular posts with an SEO-friendly post status.
add_filter( 'perflocale/seo/hreflang_include_fallbacks', function ( bool $enabled ): bool {
	if ( is_singular() && 'publish' === get_post_status() ) {
		return true;
	}
	return $enabled;
} );

Source src/Frontend/HreflangTags.php(opens GitHub in a new tab)

Decide whether PerfLocale injects xhtml:link hreflang alternates into the WordPress core sitemap (/wp-sitemap.xml) on this request.

The default decision is computed from the seo_sitemap_source setting:

  • core - always inject into WP core sitemap, even when an SEO plugin is also active.
  • plugin - never inject into WP core (let the SEO plugin's addon handle alternates exclusively in its own native sitemap).
  • auto (default) - skip core when an SEO plugin (Yoast / Rank Math / AIOSEO) is detected via setting or runtime constant/class. Otherwise inject into core.

This filter lets sites with unusual sitemap setups (e.g. a third-party sitemap plugin PerfLocale doesn't recognise, or a Yoast sitemap that has been disabled at the Yoast settings level) override the auto-detection.

Parameters
ParameterDescription
bool $decisionthe pre-filter decision derived from the seo_sitemap_source setting.
Returns
bool
Example5 lines
// Force injection into WP core even though Yoast is active (sitemap disabled in Yoast).
add_filter( 'perflocale/sitemap/inject_into_core', '__return_true' );

// Skip core sitemap injection unconditionally (custom sitemap plugin handles alternates).
add_filter( 'perflocale/sitemap/inject_into_core', '__return_false' );

Source src/Seo/SitemapIntegration.php(opens GitHub in a new tab)

Cap on how many translation-linked objects the sitemap integration batch-primes per post type / taxonomy.

The default is page-proportional: wp_sitemaps_get_max_urls() × the number of active languages (counted as at least 2) - i.e. the most a single sitemap page can actually consume, rather than a flat multiple of the page size. Whatever the filter returns is floored at 2,000, so you can raise the cap but not lower it below that. Selections above the cap skip bulk priming and fall back to per-entry lookups - output is identical either way.

Parameters
ParameterDescription
int $maxMaximum objects to prime.
Returns
int

Source src/Seo/SitemapRequest.php(opens GitHub in a new tab)

Modern SEO & UX 8

Filter the BCP-47 locale emitted in the Content-Language HTTP response header.

Default is the current language’s locale field normalised to hyphen-form (e.g. de_DE → de-DE); return an empty string to suppress the header on this request.

Parameters
ParameterDescription
string $locale-
object $current_langthe language object from the router.
Returns
string
Example7 lines
add_filter( 'perflocale/content_language/value', function ( string $locale, $current_lang ): string {
	// Force the generic region-less code on specific templates.
	if ( is_page( 'global-press' ) ) {
		return 'en';
	}
	return $locale;
}, 10, 2 );

Source src/Frontend/ContentLanguageHeader.php(opens GitHub in a new tab)

Filter the CSS block emitted for the cross-document View Transitions opt-in.

The default block opts in to automatic navigation transitions with a 240ms crossfade AND includes a @media (prefers-reduced-motion: reduce) guard that zeros the animation duration for users with the OS-level reduced-motion preference (navigation still completes instantly - only the crossfade is suppressed). Returning '' disables output entirely. If you override the default, keep the reduced-motion guard in your replacement CSS to preserve WCAG 2.1 SC 2.3.3 compliance.

Parameters
ParameterDescription
string $cssdefault CSS block (includes @view-transition, the 240ms crossfade rules, and the prefers-reduced-motion guard).
Returns
string
Example5 lines
// Add named transitions so the header and main content morph across pages.
add_filter( 'perflocale/view_transitions/css', function ( string $css ): string {
	return $css . 'header.site-header { view-transition-name: site-header; }
main { view-transition-name: main-content; }';
} );

Source src/Frontend/ViewTransitionsEmitter.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
bool $should_emitdefaults to true when the setting is on.
Returns
bool
Example6 lines
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;
} );

Source src/Frontend/ViewTransitionsEmitter.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
array $rules-
array $urls-
Returns
array
Example7 lines
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 );

Source src/Frontend/SpeculationRulesEmitter.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
bool $should_emit-
Returns
bool
Example6 lines
add_filter( 'perflocale/prerender/should_emit', function ( bool $should ): bool {
	if ( is_page_template( 'templates/heavy-media-gallery.php' ) ) {
		return false;
	}
	return $should;
} );

Source src/Frontend/SpeculationRulesEmitter.php(opens GitHub in a new tab)

On WP 6.8+ PerfLocale registers its prerender rule via Core’s native Speculation Rules API (wp_load_speculation_rules action), so our rule lands inside Core’s single output script.

Return false from this filter to force the fallback self-emit path instead - a standalone <script type="speculationrules" id="perflocale-speculationrules"> emitted at wp_footer priority 20. Primarily useful for integrators who want the perflocale/prerender/rules filter (fallback-path only) to remain active on modern WP, or for test environments exercising the fallback code without downgrading WordPress.

The same path can be forced globally by defining the PERFLOCALE_FORCE_SR_FALLBACK constant - the filter is a per-request escape hatch, the constant is a site-wide switch.

Parameters
ParameterDescription
bool $use_coredefaults to true when \WP_Speculation_Rules exists.
Returns
bool
Example1 line
add_filter( 'perflocale/prerender/use_core_api', '__return_false' );

Source src/Frontend/SpeculationRulesEmitter.php(opens GitHub in a new tab)

Filter whether to wrap singular-post content with <div data-nosnippet> when the visitor is viewing a non-default-language URL that’s showing the default-language post as fallback (missing_translation_action = show_default).

Fires only when fallback is already detected; use this to exempt specific post types or IDs. The guard applies to both explicitly-linked default-language posts AND posts that have no translation-link entry (treated as default-language, matching the plugin-wide convention), so pages authored before PerfLocale was installed are protected too.

Parameters
ParameterDescription
bool $wrap(default true)
int $post_id-
Returns
bool
Example7 lines
add_filter( 'perflocale/fallback/wrap_nosnippet', function ( bool $wrap, int $post_id ): bool {
	$post = get_post( $post_id );
	if ( $post && $post->post_type === 'product' ) {
		return false; // Let product descriptions through - SKUs are language-agnostic.
	}
	return $wrap;
}, 10, 2 );

Source src/Translation/FallbackSnippetGuard.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string $tag-
int $post_id-
Returns
string
Example1 line
add_filter( 'perflocale/fallback/nosnippet_tag', fn( $tag ) => 'span' );

Source src/Translation/FallbackSnippetGuard.php(opens GitHub in a new tab)

Core filter - applied by PerfLocale on both paths

The WordPress Core filter wp_speculation_rules_href_exclude_paths is honoured for PerfLocale’s prerender URLs on both code paths, because PerfLocale applies it itself. Core applies it only to its own main document rule - plugin-added list-source rules structurally cannot carry a where clause (WP_Speculation_Rules::add_rule() rejects it), so they never inherit Core’s exclusions. On WP 6.8+ add_core_rule() therefore runs the filter over the translation URLs before calling add_rule(), and on WP 6.4-6.7 the fallback path runs the same filter before emitting its own script. Exclusions like /cart/* or /checkout/* hold identically across WP versions.

// Exclude action URLs from speculative prerendering.
add_filter( 'wp_speculation_rules_href_exclude_paths', function ( array $paths, string $mode ): array {
	if ( $mode === 'prerender' ) {
		$paths[] = '/add-to-cart/*';
		$paths[] = '/wishlist-toggle/*';
	}
	return $paths;
}, 10, 2 );

Parameters: array $paths, string $mode (prefetch or prerender - PerfLocale always passes prerender).
File: WordPress Core 6.8+  |  src/Frontend/SpeculationRulesEmitter.php - apply_exclude_paths_filter(), called from both add_core_rule() (WP 6.8+) and emit_fallback() (WP 6.4-6.7).

Language Switcher 8

Filter the HTML attributes for each per-language link in the switcher - inline, dropdown, list, and flags renderers alike.

Add analytics data-*, inject rel / title, override hreflang / lang / dir, or append additional CSS classes without forking the template.

Values are escaped via esc_attr() (or esc_url() for href). Boolean true emits an HTML boolean attribute; false / null omits the attribute entirely. Attribute names are sanitized to [A-Za-z0-9:_-].

Base attributes the switcher already sets on each option (the filter is your chance to add to or override them):

  • href - Target translation URL.
  • class - Per-renderer (perflocale-dd__option, perflocale-switcher-block__item, …).
  • hreflang - Target language slug.
  • lang - Target language slug. Lets screen readers pronounce "Français" / "العربية" with the right phonemes since the option label is in the target language, not the page language.
  • dir - rtl when the target language's text_direction is RTL; omitted otherwise. Lets RTL labels render right-to-left even on an LTR page.
  • role, tabindex, aria-selected - Set only on dropdown options for listbox semantics.

Dev-mode guard: a non-array return triggers _doing_it_wrong() and the renderer falls back to the built-in attribute map for that option.

Parameters
ParameterDescription
array $attrsAttribute name => value pairs.
object $langLanguage object for this link (slug, name, native_name, locale, flag, text_direction).
string $current_slugCurrently active language slug.
array $argsSwitcher arguments (template / display / layout / className …).
Returns
array
Example14 lines
// 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 );

Source src/Frontend/LanguageSwitcher.php(opens GitHub in a new tab)

Override the chevron icon rendered inside the dropdown trigger button.

Three built-in styles ship - single (one down chevron, the classic native-select look, default), double (stacked up + down chevrons), none (empty string). The filter fires AFTER the built-in lookup so it can substitute markup for any style key, including none - useful for themes that want to inject an icon even when the user picked "no icon" in Settings → Language Switcher.

Returned HTML is sanitized through kses_switcher() at every echo site - if your markup uses tags or attributes outside the switcher's built-in allowlist (e.g. <i> elements, data-* attributes, additional SVG tags), extend the allowlist via perflocale/switcher/kses_allowed_html below. Returning a non-string falls back to empty.

Parameters
ParameterDescription
string $htmlComputed arrow HTML for $style (empty when $style is none or unknown).
string $styleResolved style key (single, double, none, or any custom keyword a theme uses as a sentinel).
Returns
string
Example10 lines
// Use a Font Awesome icon instead of the inline SVG chevron.
add_filter( 'perflocale/switcher/arrow_html', function ( string $html, string $style ): string {
	return '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
}, 10, 2 );

// Use a brand SVG, but only when the user picked the "double" style.
add_filter( 'perflocale/switcher/arrow_html', function ( string $html, string $style ): string {
	if ( $style !== 'double' ) { return $html; }
	return '<svg viewBox="0 0 20 20" width="1em" height="1em" aria-hidden="true"><use href="#brand-double-chevron"/></svg>';
}, 10, 2 );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

Inject HTML at the START of the dropdown panel, inside the role="listbox" container, before the first option.

Useful for region groupings, search inputs, recently-used pickers, "Powered by …" notices, or any other panel chrome.

Items injected here are NOT options, so they should not carry role="option". Put non-interactive chrome inside a <div role="presentation"> so the parent listbox doesn't announce it as an option. Returned HTML is sanitized through kses_switcher() - extend the allowlist via perflocale/switcher/kses_allowed_html if your markup needs more.

Parameters
ParameterDescription
string $htmlHTML to inject (default empty).
array $languagesLanguages about to be rendered (objects with slug, name, native_name, locale, flag, text_direction).
string $current_slugCurrently active language slug.
array $attributesResolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).
Returns
string
Example3 lines
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 );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

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.

Parameters
Same as panel_before.
Returns
string
Example5 lines
// 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>';
} );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

Filter the inner HTML of each per-language switcher option - the content that sits between the wrapping <a> / <span> on every render surface (dropdown, inline, simple, list).

Wraps the flag + label by default; the filter lets integrators inject region badges, mixed-direction <bdi> wraps, custom icons, script-tag indicators, etc., without forking the renderer. The same hook fires from both the dropdown and the inline / simple / list paths, so a single callback covers every option surface.

The filter return is passed through kses_fragment() (the switcher's internal kses helper, same allowlist as kses_switcher()) before being concatenated into the output, so unsafe HTML in a filter callback is stripped on every render path - including the Gutenberg block render path, which WordPress doesn't otherwise sanitize. Tags / attributes outside the built-in switcher allowlist (e.g. <bdi>, <i> icon-font glyphs, data-*) must be permitted via perflocale/switcher/kses_allowed_html.

Parameters
ParameterDescription
string $innerDefault inner HTML (flag <span> + label <span>, either of which may be omitted depending on the showFlags / showNames / style settings).
object $langLanguage object for this option (slug, name, native_name, locale, flag, text_direction).
string $current_slugCurrently active language slug.
array $attrsResolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).
Returns
string
Example12 lines
// 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>';
} );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

Extend the wp_kses allowlist used to sanitize switcher HTML before addons echo it (via LanguageSwitcherBlock::kses_switcher()).

Needed when a custom arrow_html, panel_before, panel_after, option_content, or link_attrs filter emits tags / attributes outside the switcher's built-in set (which covers nav, button, div, span, a, svg, path with their standard switcher attributes plus lang, dir, aria-*, tabindex, id).

The map follows the standard wp_kses_allowed_html shape: [ 'tag' => [ 'attr' => true, … ], … ].

Parameters
ParameterDescription
array $allowedCurrent allowlist (already merged with the post-context tags + the switcher's structural tags / attributes).
string $htmlThe HTML being sanitized, for context-aware decisions.
Returns
array
Example11 lines
// Analytics addon: allow data-tracking-id on options.
add_filter( 'perflocale/switcher/kses_allowed_html', function ( array $allowed ): array {
	$allowed['a']['data-tracking-id'] = true;
	return $allowed;
} );

// Theme swaps the chevron for an <i> icon-font glyph.
add_filter( 'perflocale/switcher/kses_allowed_html', function ( array $allowed ): array {
	$allowed['i'] = [ 'class' => true, 'aria-hidden' => true ];
	return $allowed;
} );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

Conditional Content 1

Extend the wp_kses allowlist used to escape conditional-content block and shortcode output.

The default is core’s post allowlist extended with the elements and attributes WordPress core’s own block renderers emit - <iframe> (oEmbeds), inline <svg>, and srcset/sizes on responsive images - so ordinary block content survives escaping. If a page builder or custom block emits markup that still gets stripped, restore it here rather than disabling escaping.

Parameters
ParameterDescription
array $allowedAllowed HTML map, standard wp_kses_allowed_html shape.
string $contentThe rendered content being escaped, for context-aware decisions.
Returns
array
Example4 lines
add_filter( 'perflocale/conditional_content/allowed_html', function ( array $allowed, string $content ): array {
	$allowed['custom-element'] = [ 'class' => true, 'data-config' => true ];
	return $allowed;
}, 10, 2 );

Source src/Frontend/ConditionalContent.php(opens GitHub in a new tab)

String Translation 2

Filter directory patterns excluded from string scanning.

Defaults to vendor/, node_modules/, .git/, tests/, test/, cache/, build/ and dist/.

Patterns are matched relative to the directory being scanned, not against the file’s absolute path. So a vendor/ folder inside the tree you asked to scan is still skipped, while an installation that merely lives below a directory of that name - /var/www/test/, a checkout under build/ - is scanned normally.

Dev-mode guard: a non-array return triggers _doing_it_wrong() and the scanner falls back to the built-in excluded-paths list.

Parameters
ParameterDescription
array $excludedArray of directory patterns.
Returns
array

Source src/Strings/StringScanner.php(opens GitHub in a new tab)

Machine Translation 19

Filter translated texts after they are returned from a machine translation provider.

Dev-mode guard: returns that are not array<int,string> trigger _doing_it_wrong() and the provider response is used unfiltered.

Parameters
ParameterDescription
array $translatedTranslated text strings.
array $originalsOriginal text strings.
string $target_langTarget language code.
string $provider_idProvider identifier.
Returns
array

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Short-circuit a machine-translation call with your own cached translation.

PerfLocale bundles no translation memory - this is the single hook point where an integration can answer the lookup itself. Return a non-empty string and it is used verbatim: the provider is never contacted, no provider quota is spent, and the REST response reports "source": "tm" instead of "mt". Two caveats: the string is not passed through wp_kses() the way a provider response is, so return markup you already trust; and the request still counts against PerfLocale’s own hourly budgets (perflocale/mt/rate_limit, perflocale/mt/rate_limit_site) because the rate-limit check runs before the lookup. Return null (the default) to fall through to the provider.

Scope: the block-editor translate endpoints only - perflocale/v1/block-translate on its single-text path (a batch request covering several blocks always goes to the provider, since a multi-leaf walk rarely has every leaf hit) and perflocale/v1/block-translate/from-source, the “fill from source” action. It does not run for whole-post translation, bulk machine-translation jobs, or string translation.

Parameters
ParameterDescription
string|null $translationnull by default (call the provider).
string $textthe source text.
string $source_langsource language slug.
string $target_langtarget language slug.
Returns
a non-empty string to use as the translation; anything else (including '', false, or an array) falls through to the provider.
Example10 lines
// Answer from a house translation memory before spending provider quota.
add_filter( 'perflocale/mt/pre_translate_lookup', function ( $translation, string $text, string $source_lang, string $target_lang ) {
	if ( is_string( $translation ) && $translation !== '' ) {
		return $translation; // Another integration already answered.
	}

	$hit = get_transient( 'acme_tm_' . md5( $source_lang . '|' . $target_lang . '|' . $text ) );

	return is_string( $hit ) && $hit !== '' ? $hit : null;
}, 10, 4 );

Source src/Api/BlockTranslateController.php(opens GitHub in a new tab)

Decide per language whether the auto-translate flow may (re)translate a post.

When a default-language post is published, PerfLocale queues a background job that walks every active language and machine-translates the post into it. Before each language it computes a default decision: translate when there is no translation yet, or when the existing translation is an untouched stub (created with empty content). A sibling with non-empty content is assumed to be human-owned and is skipped - republishing the source must never drop raw MT over a translator’s refined text.

Return true to force a re-translation of an already-filled language, or false to skip one. The filter runs after the default language is excluded and after the Auto-Translate Target Languages scope (mt_auto_translate_languages) has been applied, so it only sees languages that were already in play.

Parameters
ParameterDescription
bool $shouldthe computed decision (true when the language has no translation or only an empty stub).
int $post_idthe source post being auto-translated.
string $lang_slugthe target language slug.
int $existing_idthe existing translation’s post ID, or 0 when none exists.
Returns
bool - false skips this language for this run.
Example13 lines
// A fast-moving feed post type is safe to re-machine-translate on every
// republish - except where an editor has taken ownership of the copy.
add_filter( 'perflocale/mt/should_auto_translate', function ( bool $should, int $post_id, string $lang_slug, int $existing_id ): bool {
	if ( get_post_type( $post_id ) !== 'market_feed' ) {
		return $should;
	}

	if ( $existing_id > 0 && get_post_meta( $existing_id, '_editor_owned', true ) === 'yes' ) {
		return false;
	}

	return true;
}, 10, 4 );

Source src/Bootstrap.php(opens GitHub in a new tab)

Whether a machine-translation path sends a password-protected post’s title, content and excerpt to the provider.

Every path that sends a post asks this filter, only for a post that has a password; a post without a password is sent without asking. By default a path sends such a post only when someone picked that post; the automatic runs and the runs over every post do not. $send starts at the path’s default, named by $trigger:

  • auto_publish - Auto-Translate on Publish. Default false: the post is not translated.
  • auto_create - Auto-Translate on Create (the + Create action). Default false: + Create makes a copy of the original without contacting the provider.
  • site_translate - the site-wide job (the Translate the entire site panel and the REST site_wide mode). Default false: the post’s pairs count as skipped.
  • bulk - the bulk action on posts selected on the Translations screen, inline or as a background job. Default true.
  • cli_ids - wp perflocale translate <id> and wp perflocale translate --post-ids. Default true.
  • cli_all - wp perflocale translate --all, in the WP-CLI process or, with --async, as a background job. Default false: the post counts as skipped.
  • rest - POST /machine-translate, POST /machine-translate/object and POST /translations/bulk-translate with post_ids. Default true.
  • ability - the perflocale/translate-post ability. Default true.
  • editor - the block editor’s Fill in from source and the block toolbar’s Translate action on the post being edited. Default true.

A path that is refused skips the post and reports it as it reports any skipped item: bulk runs, the site-wide job and wp perflocale translate --post-ids or --all count it as skipped; wp perflocale translate <id> prints a warning; the REST routes and the editor answer 403 password_protected, and the ability returns a password_protected error, with the message “This post is password-protected and was not sent for machine translation.”

Parameters
ParameterDescription
bool $sendwhether to send the post: the default of $trigger above.
\WP_Post $postthe password-protected post whose text would be sent.
string $triggerone of the triggers above.
Returns
bool.
Example7 lines
// Let every path, the automatic runs and --all included, send the password-protected pages of one type.
add_filter( 'perflocale/mt/send_password_protected', function ( bool $send, \WP_Post $post, string $trigger ): bool {
	return $send || 'member_page' === $post->post_type;
}, 10, 3 );

// Never send a password-protected post, whichever path asks.
add_filter( 'perflocale/mt/send_password_protected', '__return_false' );

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Filter the wp_remote_request() arguments right before an outbound call to a machine-translation provider.

Use it to raise the timeout for bulk jobs, inject proxy/tracing headers, set a custom user-agent, or route calls through a corporate gateway.

The default SSRF validation runs before this filter and cannot be bypassed: the URL is verified against the internal/private-IP blocklist first, then the filter gets to tweak args.

Parameters
ParameterDescription
array $argswp_remote_request() argument array (timeout, headers, body, method, sslverify …).
string $urlDestination URL (already SSRF-validated).
string $provider_idProvider slug (deepl, google, microsoft, libretranslate, external_agency, wp_ai_client).
Returns
array
Example10 lines
// 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 );

Source src/MachineTranslation/AbstractProvider.php(opens GitHub in a new tab)

Filter the list of hosts that skip the address checks PerfLocale runs on a machine-translation provider URL.

It has two uses:

  • Reaching a provider on an internal network. A provider URL whose host is, or resolves to, a private, shared or reserved address is refused, and the refusal message names this filter. To use a self-hosted LibreTranslate on your LAN, or on a Tailscale or carrier-grade NAT address (the shared range 100.64.0.0/10), add the host name or IP address of its URL here.
  • Skipping the DNS step for a public provider. A custom provider host (for example a custom AI translator add-on calling api.cohere.ai) listed here skips the slow gethostbyname() lookup, which has no timeout.

Important: a listed host bypasses every check that follows the filter - treat the list as a security-relevant setting. A host you add here returns from validate_url() immediately, so the IPv4 private, shared and reserved range checks, the IPv6 unique-local (fc00::/7) and link-local (fe80::/10) checks, the DNS resolution, and the checks on the resolved address are all skipped for it, whether you add a host name or an IP address. Only the hard-coded localhost list (localhost, 127.0.0.1, 0.0.0.0, ::1, [::1]) is checked before the filter and therefore still applies. Add only hosts you control. Entries are normalised (non-strings dropped, then lowercased); the return value is cast with (array), so returning a non-array does not restore the built-in list - a string becomes a one-element trusted list, and null leaves no trusted hosts at all, sending every request (including the five bundled providers) through the DNS step.

Default trusted hosts (built into PerfLocale) are exactly four endpoints: translation.googleapis.com, api.deepl.com, api-free.deepl.com, api.cognitive.microsofttranslator.com. LibreTranslate, hosted or self-hosted, is not trusted by default. Anything else - a self-hosted LibreTranslate, a custom agency endpoint, an AI gateway - goes through the DNS step and the address checks unless you add it here.

Parameters
ParameterDescription
array<int, string> $hostsLowercase trusted hostnames. Add to or replace this array.
Returns
array
Example12 lines
// A self-hosted LibreTranslate on the office LAN and one on Tailscale.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
	$hosts[] = 'libretranslate.office.lan';
	$hosts[] = '100.101.102.103';
	return $hosts;
} );

// A public custom provider host that skips the DNS step.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
	$hosts[] = 'api.cohere.ai';
	return $hosts;
} );

Source src/MachineTranslation/AbstractProvider.php(opens GitHub in a new tab)

Upper cap (seconds) on how long a provider’s Retry-After header can make the worker sleep after a 429 rate-limit response.

Default 300 - matches what real providers advise during sustained overload, while stopping a malicious or buggy provider returning Retry-After: 86400 from blocking a PHP worker for ~24h per retry. On a 429 the worker first takes max( Retry-After, exponential-backoff ), then caps that at this value, so the effective sleep is min( max( retry_after, 2^attempt ), this_cap ). The cap itself is floored at 1s.

Parameters
ParameterDescription
int $max_secondsdefault 300.
Returns
int
Example2 lines
// Never sleep more than a minute on a rate-limit backoff.
add_filter( 'perflocale/mt/retry_after_max_seconds', fn(): int => 60 );

Source src/MachineTranslation/AbstractProvider.php(opens GitHub in a new tab)

Filter the source text right before it is sent to the MT provider.

Lets you mask placeholders, strip shortcodes, or pre-substitute branded terms.

Parameters
ParameterDescription
string $text-
string $source_lang-
string $target_lang-
string $provider_id-
Returns
string
Example4 lines
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 );

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Filter the translated text after the MT provider returns it, and after the kses sanitisation pass.

The fifth argument is the provider id so a single callback can branch by provider.

Parameters
ParameterDescription
string $translated-
string $source-
string $source_lang-
string $target_lang-
string $provider_id-
Returns
string
Example7 lines
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 );

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Handle responses from the External Agency provider when the agency does not return a synchronous translation in the immediate POST response.

PerfLocale does not ship a built-in async callback receiver; this filter is the supported extension point for addons that want to add one. The default return is null, which makes the provider throw RuntimeException instead of saving the untranslated source text into the translation.

Return a non-empty string to use as the translation. Return null (or empty string) to fall through to the throw - appropriate when the addon hasn't received the agency callback yet.

When this fires: the agency returned a 2xx response but the decoded body has no translation string key. PerfLocale then throws, rather than returning the source text as a translation, unless this filter supplies a string. See Machine Translation → External Translation Agency.

Parameters
ParameterDescription
string|null $translationDefault null (causes the provider to throw).
array $responseDecoded agency response body.
string $request_idUUIDv4 generated for this outbound request. Use it to correlate against a previously-received agency callback.
array $payloadOutbound payload sent to the agency: text, source_lang, target_lang, request_id.
array $contextProvider context: source_lang, target_lang, provider_id (always external_agency).
Returns
string|null
Example19 lines
// Sketch of an out-of-tree async receiver: an addon registers its own
// REST route (e.g. POST /mycorp/v1/agency-callback) with HMAC verification,
// stashes incoming translations into an option/transient keyed by
// request_id, and matches them back here when the next translate() runs.
add_filter( 'perflocale/mt/agency_async_response', function (
	$translation,
	array $response,
	string $request_id,
	array $payload,
	array $context
) {
	$cached = get_option( 'mycorp_agency_cb_' . $request_id );
	if ( is_array( $cached ) && isset( $cached['translation'] ) ) {
		delete_option( 'mycorp_agency_cb_' . $request_id );
		return (string) $cached['translation'];
	}
	// No callback yet - let the core throw so the job fails loudly.
	return null;
}, 10, 5 );

Source src/MachineTranslation/Provider/ExternalAgencyProvider.php(opens GitHub in a new tab)

The machine-translatable meta-key registry.

Register only leaf text values a human translator would rewrite - plain text, textarea, and rich-text fields. Never structural keys (repeater counts, layout arrays), URLs/emails, serialized arrays, or SEO focus keywords; those belong on perflocale/translatable_meta_keys (the copy/seed list), not here. SEO addons register titles/descriptions here when the "Translate SEO meta" setting is on; ACF/MetaBox/Pods register text fields when "Translate custom fields" is on.

After the filter, PerfLocale drops two kinds of key: any key matching perflocale/translation/dangerous_meta_patterns (never sent to a provider or overwritten), and any mirror key from perflocale/sync/mirror_meta_keys. The fourth argument names the translation the values will be written to, so a callback can compare the two posts. The bundled ACF addon uses it to leave out repeater and flexible-content rows whose structure on the translation no longer matches the source; those keys are counted as skipped.

Parameters
ParameterDescription
array $keysMeta keys to machine-translate. Default [].
string $post_typePost type being translated.
int $post_idSource post ID (0 for type-level cost estimates).
int $translation_idTranslation post ID the values are written to (0 when unknown, as in the cost estimate).
Returns
array
Example6 lines
add_filter( 'perflocale/mt/translatable_meta_keys', function ( array $keys, string $post_type, int $post_id = 0, int $translation_id = 0 ): array {
	if ( $post_type === 'product' ) {
		$keys[] = '_my_product_tagline'; // A plain-text field.
	}
	return $keys;
}, 10, 4 );

Source src/MachineTranslation/MetaTranslator.php (opens GitHub in a new tab) (the cost estimate in src/MachineTranslation/CostEstimator.php (opens GitHub in a new tab) asks it through the same method)

Choose the machine-translation destination format for an individual meta key.

Return 'text' for plain-text meta (SEO title/description, plain custom fields) so the provider is called in text mode and the result is not entity-escaped; return 'html' (the default) for markup-bearing meta. Unknown keys stay 'html'. PerfLocale groups a post’s translatable meta by resolved format and makes one provider round-trip per format, so declaring a key as text keeps literal characters like & intact instead of turning them into &amp;. The bundled SEO and ACF/MetaBox/Pods addons use this hook to route plain-text fields to text mode. The fourth argument is the source post, so the format can depend on the post’s own data; the ACF addon uses it to route each row of that post by the row’s own field type, so a field name that is plain text in one flexible-content layout and rich text in another is handled row by row.

Parameters
ParameterDescription
string $format'html' (default) or 'text'.
string $keyMeta key.
string $post_typeSource post type.
int $source_idSource post ID.
Returns
string
Example6 lines
add_filter( 'perflocale/mt/meta_key_format', function ( string $format, string $key, string $post_type, int $source_id = 0 ): string {
	if ( $key === '_my_seo_title' ) {
		return 'text'; // Plain text - don't entity-escape.
	}
	return $format;
}, 10, 4 );

Source src/MachineTranslation/MetaTranslator.php(opens GitHub in a new tab)

Meta keys with MIRROR semantics during content sync: the sibling ends up holding exactly the source’s rows, and an absent or empty source value clears the sibling.

Rows identical to the source’s are left alone rather than rewritten. Which keys this set governs is narrower than it looks. Seed-only semantics - copied once into a sibling that has none, then owned by that sibling and never overwritten or deleted - are computed from the add-on-contributed keys (perflocale/translatable_meta_keys) plus anything perflocale/sync_fields/for_post appends, minus this set. Seeds are copied only from the group’s default-language post: a value first typed on a translation stays on that translation. A seed-only key whose name matches perflocale/translation/dangerous_meta_patterns is not synced at all, and a key listed in the translation’s _perflocale_seed_cleared meta (the Meta Box addon writes it when a field that held a value is saved empty there) stays empty. The configured Sync fields list and anything added through perflocale/sync_fields mirror unconditionally and cannot be made seed-only from here, so never put a value carrying translatable text into either of those. Mirror keys are also excluded from machine translation.

⚠️ Never put a value that contains translatable text on this list. The mirror is bidirectional: any group member’s save propagates to all the others, so a key holding words is destroyed in both directions - the translator saves and overwrites the original, then the next original save overwrites the translation. Page-builder layout documents hold their text inside them, so the bundled page-builder add-ons treat them (_elementor_data, _fl_builder_data, Oxygen’s _ct_builder_json and its older ct_builder_json spelling, _bricks_page_content, Oxygen 6’s data) as seed-only, and mirror only text-free keys such as Beaver’s _fl_builder_enabled.

Use mirror for values that are genuinely source-owned and carry no words: a flag, an ID, a numeric setting. If you want a structural change to propagate and translated text to survive, you need a format-aware merge that can tell a layout node from a localisable value - a whole-document mirror cannot give you both.

Parameters
ParameterDescription
array $keysMeta keys to mirror (defaults to the configured sync fields).
string $post_typePost type being synced.
Returns
array
Example10 lines
add_filter( 'perflocale/sync/mirror_meta_keys', function ( array $keys, string $post_type ): array {
	// Source-owned, and contains no translatable text.
	$keys[] = '_my_layout_enabled';   // A flag deciding whether the layout renders.
	$keys[] = '_my_grid_column_count'; // A number.

	// NOT the document that holds headings, paragraphs and button labels:
	// mirroring that destroys a translator's work in both directions.

	return $keys;
}, 10, 2 );

Source src/Translation/ContentSync.php (opens GitHub in a new tab), src/MachineTranslation/MetaTranslator.php (opens GitHub in a new tab), addons/woocommerce/PerfLocaleWooCommerce.php (opens GitHub in a new tab) (asked for product_variation (opens GitHub in a new tab) and product (opens GitHub in a new tab), so mirrored variation descriptions and product attributes are left out of machine translation)

Filters one row of a seed-only meta key (see perflocale/sync/mirror_meta_keys) before content sync copies it into a sibling translation.

It runs only when a seed is written: the default-language post is saved, the sibling has no rows for the key and has not emptied it on purpose, and the source holds a value. Each row of a multi-value key is filtered on its own. The value arrives unslashed; return the row to write, and PerfLocale slashes it. The bundled Meta Box add-on uses this filter to take a group’s password sub-fields out of the seeded rows, as it does when a translation is created.

Parameters
ParameterDescription
mixed $meta_valuethe row as read from the source, unslashed.
string $fieldthe meta key.
int $target_idthe sibling translation that receives the row.
int $source_idthe default-language post the row comes from.
Returns
mixed - the row to write. Default: the source’s row unchanged.
Example8 lines
add_filter( 'perflocale/sync/seed_meta_value', function ( mixed $value, string $key, int $target_id, int $source_id ): mixed {
	// Keep a token stored inside a settings array out of the translations.
	if ( '_my_box_settings' === $key && is_array( $value ) ) {
		unset( $value['api_token'] );
	}

	return $value;
}, 10, 4 );

Source src/Translation/ContentSync.php (opens GitHub in a new tab); listener in addons/metabox/PerfLocaleMetabox.php (opens GitHub in a new tab)

Shortcodes whose tags go to machine translation with their attributes, because the attributes hold visible text. Default: caption.

Every other registered shortcode's tags are held back from machine translation and restored in the translation unchanged.

Parameters
ParameterDescription
string[] $tagsShortcode names. Default: [ 'caption' ].
Returns
string[]
Example5 lines
// A gallery shortcode whose title attribute is visible text.
add_filter( 'perflocale/mt/text_shortcodes', static function ( array $tags ): array {
	$tags[] = 'my_gallery';
	return $tags;
} );

Source src/MachineTranslation/ShortcodeMask.php(opens GitHub in a new tab)

WordPress 6.9+ / 7.0+ Integrations (feature-detected - inert on earlier WordPress versions) 11

Hooks that wire PerfLocale into recent WordPress APIs: the Abilities API and its JS-side shim (WordPress 6.9+), and the AI Client, the Connectors API, and Block Hooks (WordPress 7.0+). Each surface is feature-detected - the Abilities filters (perflocale/abilities/js_screens, perflocale/abilities/js_payload) light up once wp_register_ability() exists (WordPress 6.9+); the AI Client surfaces require WordPress 7.0+. On a site without the relevant API these filters still fire but their underlying code paths stay inert.

Override the callable that WpAiClientProvider invokes.

Useful for tests, custom in-house AI gateways, or as a forward-compatibility shim if WordPress core renames the underlying function.

Parameters
ParameterDescription
null|callable $resolverCallable accepting (string $prompt, array $args). Return null to fall through to auto-detection.
Returns
string
Example6 lines
add_filter( 'perflocale/mt/wp_ai_client_resolver', function () {
	return function ( string $prompt, array $args ): string {
		// Route to your own gateway and return raw model text.
		return my_gateway_complete( $prompt, $args );
	};
} );

Source src/MachineTranslation/Provider/WpAiClientProvider.php(opens GitHub in a new tab) src/Admin/Pages/SettingsPage.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string $prompt-
string $text-
string $source_lang-
string $target_lang-
string $formatDestination format: html (markup kept) or text (plain text, no markup or entities). Passed to callbacks that accept five arguments.
Returns
string
Example4 lines
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 );

Source src/MachineTranslation/Provider/WpAiClientProvider.php(opens GitHub in a new tab)

Filter the argument array passed to the AI Client call.

The default is [ 'capability' => 'text-generation', 'temperature' => 0.2, 'timeout' => 10 or 60, 'model' => [ small models ] ]. Recognised keys:

  • model - a model ID ('gpt-5.4-mini') or an ordered list of preferences, each a model ID or a [ provider_id, model_id ] pair. It is passed to WordPress’s model preference: the first one a connected provider offers is used, otherwise WordPress chooses. The default is an ordered list of small models (OpenAI mini, Anthropic Haiku, Google Flash). null or [] leaves the choice to WordPress. Entries of any other shape are dropped.
  • provider - a provider ID, to use only that provider.
  • temperature - default 0.2; null sends none. While a temperature is sent, a model that does not advertise temperature support is not chosen, so an explicit model of that kind needs 'temperature' => null too. When a model refuses the temperature, the call is repeated once without it.
  • max_tokens, system_instruction.
  • timeout - seconds for the provider request: 10 for editor calls ($fast_fail true), 60 otherwise.
Parameters
ParameterDescription
array $args-
bool $fast_fail-
Returns
array
Example5 lines
// Prefer one model, then another from a named provider.
add_filter( 'perflocale/mt/wp_ai_client_args', function ( array $args, bool $fast_fail ): array {
	$args['model'] = [ 'gpt-4.1-mini', [ 'anthropic', 'claude-haiku-4-5' ] ];
	return $args;
}, 10, 2 );

Source src/MachineTranslation/Provider/WpAiClientProvider.php(opens GitHub in a new tab)

Retarget the Block Hooks auto-insertion of the language-switcher block to a different anchor.

The default is core/site-title:after. Return a [ block_type, position ] pair, or false to keep the default.

Parameters
ParameterDescription
array|false $override-
string $anchor_block_type-
string $relative_position-
Returns
array|false
Example2 lines
// Put the switcher at the end of the site navigation instead of after the title.
add_filter( 'perflocale/switcher/auto_insert_anchor', fn() => [ 'core/navigation', 'last_child' ] );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
array $attrs-
string $anchor_block_type-
string $relative_position-
array|null $parsed_anchor_block-
Returns
array
Example5 lines
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 );

Source src/Frontend/LanguageSwitcherBlock.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string[] $screens-
Returns
array
Example4 lines
add_filter( 'perflocale/abilities/js_screens', function ( array $hooks ): array {
	$hooks[] = 'toplevel_page_my-custom-editor';
	return $hooks;
} );

Source src/Admin/Assets.php(opens GitHub in a new tab)

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, ... }, ... ] ].

Parameters
ParameterDescription
array $payload-
Returns
array
Example7 lines
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;
} );

Source src/Admin/Assets.php(opens GitHub in a new tab)

Filter the permission gate for the perflocale/convert-url ability (its REST/MCP endpoint).

Default requires a logged-in user. Only consulted when the Abilities API integration is enabled.

Parameters
ParameterDescription
bool $alloweddefault is_user_logged_in().
Returns
bool
Example2 lines
// Allow anonymous URL conversion through the ability.
add_filter( 'perflocale/abilities/convert_url_permission', '__return_true' );

Source src/AbilitiesRegistrar.php(opens GitHub in a new tab)

Supply the callable PerfLocale uses to look up the MT-provider keys (and any key added through perflocale/connectors/slug_map) after environment variables and constants and before the database.

It receives a slug (deepl, google-cloud-translation, microsoft-translator, libretranslate) and returns the key, or null or an empty string when it has none. Current WordPress releases do not hand these keys out, so the built-in lookup finds none of them: return a resolver when your site keeps them in its own store. Also useful for tests.

Parameters
ParameterDescription
null|callable $resolver-
Returns
?string
Example6 lines
add_filter( 'perflocale/connectors/resolver', function () {
	return function ( string $slug ): ?string {
		// Pull from your own secrets store.
		return MyVault::get_key( $slug );
	};
} );

Source src/Settings.php(opens GitHub in a new tab)

Extend the setting-key → connector-slug map to look up additional API keys through the resolver (the defaults cover the 4 MT providers only).

PerfLocale ships no GeoIP or exchange-rate provider of its own, so any key those surfaces need is one your site registered - add it here to have your resolver supply it too. Unknown connector slugs simply return null from the resolver and fall through to env / constant / DB as usual.

Parameters
ParameterDescription
array<string, string> $mapperflocale_setting_key => connector_slug.
Returns
array
Example6 lines
add_filter( 'perflocale/connectors/slug_map', function ( array $map ): array {
	// Setting keys registered by your own geo / FX provider wiring.
	$map['my_geo_provider_key'] = 'acme-geo';
	$map['my_fx_api_key']       = 'acme-fx';
	return $map;
} );

Source src/Settings.php(opens GitHub in a new tab)

Query Filtering 5

Override language filtering to include all languages in a query.

Parameters
ParameterDescription
bool $includeWhether to include all languages (default false).
WP_Query $queryThe query object.
Returns
bool
Example6 lines
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 );

Source src/Translation/PostQueryFilter.php(opens GitHub in a new tab)

Master switch for the found-rows pagination optimization.

On language-scoped archive queries PerfLocale replaces WordPress core’s SQL_CALC_FOUND_ROWS full-scan with no_found_rows plus a generation-cached COUNT, reproducing WP_Query::set_found_posts() branch-for-branch so pagination totals stay correct. Return false to keep core’s SQL_CALC_FOUND_ROWS behaviour. It only evaluates for a non-singular main archive query that is not a search, has not already opted out of counting and carries PerfLocale’s language JOIN - all / empty fields, a non-zero perflocale_language_id, on a site that has translation groups.

Parameters
ParameterDescription
bool $enabled(default true)
\WP_Query $query-
Returns
bool
Example4 lines
add_filter( 'perflocale/query/optimize_found_rows', function ( bool $enabled, \WP_Query $query ): bool {
	// Keep core counting for one custom archive.
	return $query->get( 'post_type' ) === 'event' ? false : $enabled;
}, 10, 2 );

Source src/Translation/PostQueryFilter.php(opens GitHub in a new tab)

Post types that are TRANSLATABLE but must never be language-scoped by the query filter.

Use it for content a plugin resolves by IDENTITY - a hash, a slug, a name, a meta value - rather than browsing by language. Scoping such a lookup does not filter it, it breaks it: the lookup returns nothing and the host has no “not found” story beyond an error string or a blank region. The default set is wp_template, wp_template_part, wp_global_styles and wp_navigation; the bundled Contact Form 7, Oxygen and WPForms addons add their own types. Do NOT add a type that is genuinely browsed by language (post, page, product) - that would put every language’s content into every archive, search result and listing.

Parameters
ParameterDescription
array<int, string> $typesPost type slugs.
Returns
array
Example4 lines
add_filter( 'perflocale/query/never_scoped_post_types', function ( array $types ): array {
	$types[] = 'my_form'; // Resolved by hash, never browsed by language.
	return $types;
} );

Source src/Translation/PostQueryFilter.php(opens GitHub in a new tab)

Opt in to re-checking that a translated template part still belongs to the active theme before substituting it.

Off by default because it costs an extra lookup on a hot rendering path; turn it on if you switch themes between requests (a staging/preview setup, or a theme switcher) and would rather pay the lookup than risk serving a part from the previous theme.

Parameters
ParameterDescription
bool $verifyDefault false.
Returns
bool
Example1 line
add_filter( 'perflocale/fse/verify_part_theme', '__return_true' );

Source src/Frontend/BlockTemplateTranslator.php(opens GitHub in a new tab)

Whether saving a block template or template part fans out copies to every active language.

Return false to keep template translations entirely manual.

The filter is only reached after PerfLocale has already decided the save is a real one: revisions, autosaves and the auto-draft, trash and inherit statuses never get here, Full Site Editing must be on with the type translatable, and the saved post must itself be the default-language member of its group. Languages that already have a live translation are skipped, so a callback returning true only ever creates what is missing. Each copy is created as a draft seeded with the source’s blocks, so nothing reaches visitors until you publish it.

Parameters
ParameterDescription
bool $enabledtrue by default.
WP_Post $postThe template being saved.
Returns
bool
Example4 lines
// Fan out full templates, but leave template parts alone.
add_filter( 'perflocale/fse/auto_create_translations', function ( bool $enabled, WP_Post $post ): bool {
	return 'wp_template_part' !== $post->post_type;
}, 10, 2 );

Source src/Translation/BlockTemplateSupport.php(opens GitHub in a new tab)

Addons 8

Per-addon override of the global Delete data on uninstall setting.

Lets a single addon opt out of (or into) data deletion regardless of the plugin-wide preference. Receives the planned PurgePlan so callers can branch on which tables / option keys would be removed.

Parameters
ParameterDescription
bool $delete-
string $addon_id-
PerfLocale\Addon\PurgePlan $plan-
Returns
bool
Example7 lines
add_filter( 'perflocale/addon/delete_data_on_uninstall', function ( bool $delete, string $addon_id, $plan ): bool {
	// Always preserve the WooCommerce addon's data even if the user toggled global deletion on.
	if ( $addon_id === 'woocommerce' ) {
		return false;
	}
	return $delete;
}, 10, 3 );

Source src/Addon/AddonUninstaller.php(opens GitHub in a new tab)

Whether an addon boots in this request.

Use it to run an integration in some contexts only, for example not in WP-CLI or not on one site of a network. It is asked once per addon per request, when the registry boots addons (init priority 0 on a normal request), after the quarantine check and before the addon’s own compatibility check.

At that point only request-level context is known: admin or front end, REST (Helper::is_rest_request()), WP-CLI, cron, AJAX and the current site. The language and the queried object are not known yet, so a callback cannot depend on them.

false skips the boot for this request only. The addon’s hooks are not added, perflocale()->is_addon_active() returns false, and the Addons page shows the card as Not running when the admin request itself turned it off. This is not a failure: the addon is not reported as incompatible or quarantined, its failure counter is not changed, and the disabled list behind the Addons page switch stays as it is. true boots an addon that the disabled list turns off. Quarantined addons are skipped before this filter runs.

Theme addons. The Blocksy, Kadence and Neve addons are decided earlier, while the PerfLocale plugin file loads, so that they can register before the theme sets up. For those three, only a callback added by a must-use plugin, or by a plugin that loads before PerfLocale, takes effect. A callback in a theme’s functions.php runs too late for them.

Parameters
ParameterDescription
bool $enabledDefault false when the addon is in the disabled list (the Addons page switch), otherwise true.
string $addon_idAddon identifier.
Returns
bool
Example8 lines
// wp-content/mu-plugins/perflocale-addons.php
// Keep the WooCommerce integration out of WP-CLI runs.
add_filter( 'perflocale/addon/enabled', function ( bool $enabled, string $addon_id ): bool {
	if ( 'woocommerce' === $addon_id && defined( 'WP_CLI' ) && WP_CLI ) {
		return false;
	}
	return $enabled;
}, 10, 2 );

Source src/Addon/AddonRegistry.php(opens GitHub in a new tab) src/Bootstrap.php(opens GitHub in a new tab)

How many consecutive boot failures an addon is allowed before the registry auto-quarantines it (skipping its boot() on subsequent requests).

The default of 3 tolerates transient issues like a missing dependency during an upgrade window or a one-off OOM; raise it for flaky network-dependent addons, lower it to fail-fast in development.

The result is memoised per request so the filter only fires once even when many addons boot. Returning 0 (or a negative number) disables quarantine entirely; 1 quarantines on the first failure. Operators see quarantined addons in the Addons admin page with the last error message inline, and can reset via wp perflocale addon reset-quarantine <id>.

Parameters
ParameterDescription
int $thresholdDefault 3. Min effective value 1; values ≤ 0 disable quarantine.
Returns
int
Example8 lines
// Raise tolerance for a known-flaky addon to 10 consecutive failures.
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 10 );

// Quarantine on the very first failure (strict).
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 1 );

// Disable quarantine entirely (every failure is retried on next request).
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 0 );

Source src/Addon/AddonRegistry.php(opens GitHub in a new tab) src/Bootstrap.php(opens GitHub in a new tab) src/Admin/SiteHealth.php(opens GitHub in a new tab)

Suppress the developer-mode _doing_it_wrong nudge that fires when an addon's soft-prefix uninstall-manifest entries (transient prefixes, cron hooks, post/user/term/comment meta keys) don't include the per-addon sub-namespace (perflocale_{addon_id}_, plus _perflocale_{addon_id}_ for meta keys).

The notice is purely a DX hint - soft-prefix per-addon isolation is not enforced (see Hard vs soft prefixes), and the global perflocale_ prefix is the only namespace requirement. The notice is also WP_DEBUG-gated by core, so production users never see it. Filter it off for addons that intentionally coordinate across the addon namespace (e.g. a manager addon cleaning up after a family of related sibling addons).

Parameters
ParameterDescription
bool $checkDefault true. Return false to skip the check for this addon.
string $addon_idIdentifier of the addon whose manifest is being written.
Returns
bool
Example7 lines
// Suppress for one specific coordinating addon.
add_filter( 'perflocale/addon/manifest/check_soft_prefix_namespacing', function ( bool $check, string $addon_id ): bool {
	return 'mycorp_coordinator' === $addon_id ? false : $check;
}, 10, 2 );

// Suppress globally during a noisy migration.
add_filter( 'perflocale/addon/manifest/check_soft_prefix_namespacing', '__return_false' );

Source src/Addon/AddonManifestWriter.php(opens GitHub in a new tab)

Declare the data an add-on, Pro or another extension stores, so export, import, translation copy and Sync Fields treat it as declared.

Each entry is keyed <kind>:<name> and has every key of a core entry: kind (option, network_option, transient, site_transient, post_meta, term_meta, user_meta, order_meta or cron), name, owner (addon:<id>, pro or ext:<slug>), class, export, not_exported, import, uninstall (data or keep; always keep for cron), scope, copy, sync, deactivate, fixture and since. Entries are only added: one that collides with or overlaps a PerfLocale entry, or fails validation, is ignored.

  • An exported entry (only into the Add-on data section) must declare sanitize_import, a callable fn( mixed $value, string $name ): mixed the importer passes each imported value through before storing it. An exporting entry without one is ignored, and so is one whose name is a WordPress core option or meta key, or looks like a credential (see perflocale/data_registry/is_secret).
  • By default every add-on's exported data is chosen with the one Add-on data checkbox. An exported entry may declare group ([ 'id' => 'my-addon', 'label' => __( 'My add-on', 'my-addon' ) ]) to get its own checkbox with that label; the id is 1 to 40 lowercase letters, digits, - or _.
  • sync => 'never' for a key outside the _perflocale_ namespace is enforced when Settings are saved; to keep it out of a sync started another way, also remove it in perflocale/sync_fields.
Parameters
ParameterDescription
array $entriesEntry id => entry. Default: empty.
Returns
array
Example20 lines
add_filter( 'perflocale/data_registry/entries', static function ( array $entries ): array {
	$entries['option:my_addon_settings'] = [
		'kind'            => 'option',
		'name'            => 'my_addon_settings',
		'owner'           => 'ext:my-addon',
		'class'           => 'CONFIG',
		'export'          => 'addons',
		'not_exported'    => null,
		'import'          => 'replace',
		'uninstall'       => 'keep',
		'scope'           => 'blog',
		'copy'            => 'n/a',
		'sync'            => 'n/a',
		'deactivate'      => 'n/a',
		'fixture'         => 'none:my add-on is not in the PerfLocale fixture',
		'since'           => '1.0.0',
		'sanitize_import' => static fn( $value ) => is_array( $value ) ? $value : [],
	];
	return $entries;
} );

Source src/Database/DataRegistry.php(opens GitHub in a new tab)

Whether an extension entry whose name looks like a credential really holds a secret. Default true: the entry is not exported.

An extension entry exported into Add-on data whose name looks like a credential (the names the export redaction strips: _api_key, _secret, _password, _token, a name ending in _key and similar) is refused, so its value never travels in an export file. Return false only for a name that holds no secret, such as a _token_count counter; the entry is then exported and imported like any other.

Parameters
ParameterDescription
bool $is_secretDefault true.
string $nameThe entry's name (an option or meta key, or a name pattern).
string $idThe entry id (<kind>:<name>).
Returns
bool
Example3 lines
add_filter( 'perflocale/data_registry/is_secret', static function ( bool $is_secret, string $name ): bool {
	return 'my_addon_token_count' === $name ? false : $is_secret;
}, 10, 2 );

Source src/Database/DataRegistry.php(opens GitHub in a new tab)

WooCommerce 14

Register exchange-rate providers.

PerfLocale ships the scheduled multi-currency sync mechanism but no rate provider and no endpoint - the built-in provider list is empty, so this filter (or exchange_rates_fetched) is how a site supplies rates. Until one of them is wired, the sync contacts nothing.

Parameters
ParameterDescription
array $providersProvider definitions (see Exchange Rates docs).
Returns
array

Source src/WooCommerce/ExchangeRateSync.php(opens GitHub in a new tab)

Filter rates after the fetch step but before saving.

The simplest way to supply rates without registering a full provider: return the map you want stored. Also useful for applying a markup or overriding a single currency.

Parameters
ParameterDescription
array $ratesCurrency code => exchange rate.
string $base_currencyWooCommerce base currency code.
string $provider_idSelected provider ID (empty when no provider is registered).
Returns
array

Source src/WooCommerce/ExchangeRateSync.php(opens GitHub in a new tab)

Master switch for cross-language product-data sync, re-evaluated per request.

Defaults to the Sync Inventory Across Language Variants setting (wc_sync_stock, default on). Returning false suppresses every sibling write - product fields, variation fields, order-driven stock changes, and CSV-import syncs alike - without touching the stored setting, which makes it the right hook for a temporary freeze during a bulk operation.

This is the site-wide switch; use skip_inventory_sync to exempt a single product instead.

Parameters
ParameterDescription
bool $enabledThe wc_sync_stock setting (default true).
Returns
bool
Example4 lines
// Suspend inventory mirroring while a staged catalogue rebuild runs.
add_filter( 'perflocale/woocommerce/inventory_sync_enabled', function ( bool $enabled ): bool {
	return get_option( 'acme_catalog_rebuild_running' ) ? false : $enabled;
} );

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

The variation-level twin of synced_product_fields: controls which meta keys are copied from an edited variation onto the attribute-matched variation of every sibling-language parent.

Siblings are located by anchoring on the linked parent product and matching children on their exact normalised attribute set - a variation whose attributes a translator has diverged is skipped rather than guessed at.

Default list: _stock, _stock_status, _manage_stock, _backorders, _price, _regular_price, _sale_price, _sale_price_dates_from, _sale_price_dates_to, _sku, _global_unique_id, _weight, _length, _width, _height, _virtual, _downloadable. The five price keys are removed before the filter runs when Synchronize Prices Across Languages (wc_sync_prices) is off, so a per-language pricing setup already sees the shorter list. Returning an empty array skips the sibling sync for that variation entirely.

Parameters
ParameterDescription
array $fieldsVariation meta keys to sync.
int $variation_idThe variation being saved.
Returns
array
Example7 lines
// Mirror a custom lead-time field across languages, but let each
// market keep its own SKUs.
add_filter( 'perflocale/woocommerce/synced_variation_fields', function ( array $fields, int $variation_id ): array {
	$fields[] = '_acme_lead_time_days';

	return array_values( array_diff( $fields, [ '_sku' ] ) );
}, 10, 2 );

Source src/WooCommerce/InventoryFieldSync.php(opens GitHub in a new tab)

Whether a row written by the WooCommerce CSV product importer triggers a sibling sync.

Default true. A row with no translations costs one cached group lookup and short-circuits, but importing a fully translated catalogue runs a sibling sync per row - return false to skip it for the duration of the import and do a single resync afterwards instead.

Hooked to woocommerce_product_import_inserted_product_object, so it covers both products and variations written by the importer. Ordinary admin, REST, and quick/bulk-edit saves are unaffected.

Parameters
ParameterDescription
bool $synctrue by default.
WC_Product $productThe imported product or variation object.
Returns
bool
Example7 lines
// Skip per-row sync during CSV imports; resync in one pass afterwards.
add_filter( 'perflocale/woocommerce/sync_on_import', '__return_false' );

// Or narrow it to variations only, which are the expensive rows.
add_filter( 'perflocale/woocommerce/sync_on_import', function ( bool $sync, WC_Product $product ): bool {
	return $product instanceof WC_Product_Variation ? false : $sync;
}, 10, 2 );

Source src/WooCommerce/InventoryFieldSync.php(opens GitHub in a new tab)

Skip inventory sync for a specific product.

Parameters
ParameterDescription
bool $skipWhether to skip inventory sync for this product. Not a flat false: the incoming value is the per-product opt-out meta (_perflocale_sync_optout = yes), which a variation also inherits from its parent product. Return $skip unchanged to preserve the user's opt-out.
int $product_idProduct being synced.
Returns
bool

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

Whether Store API product data outside a cart counts the pending stock holds of every language copy of a product.

With Inventory Sync on, the language copies of a product share one stock, so the cart, add-to-cart, checkout and pay-for-order checks always count the holds of every copy (see Shared stock before payment). The quantity limit shown in Store API product data - product blocks and the Store API products route - is WooCommerce’s own by default and costs no extra query. Return true to count every copy there too, so the limit a shopper sees matches the one the cart enforces. That adds database reads for each translated, stock-managed product on those renders when no persistent object cache is in use.

Asked each time the Store API computes a product’s quantity limit outside a cart.

Parameters
ParameterDescription
bool $countDefault false.
WC_Product $productProduct or variation the limit is for.
Returns
bool
Example5 lines
// Exact limits in product blocks for low-stock items only.
add_filter( 'perflocale/woocommerce/count_shared_holds_in_catalog', function ( bool $count, WC_Product $product ): bool {
	$stock = $product->get_stock_quantity();
	return ( null !== $stock && $stock <= 5 ) ? true : $count;
}, 10, 2 );

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

Whether cart and checkout stock checks use stored stock maps.

With Inventory Sync on, the language copies of a product share one stock, so each stock check of a variation in a cart or an order first finds the matching variation of each language copy. PerfLocale stores that answer per variation for up to a day and checks it against the products on every read. Stock quantities, held stock, stock management and the per-product opt-out are always read live and never stored.

Return false to look the matching variations up on every check instead, for example to rule the stored maps out while you look into a stock question. Each cart, checkout and Store API cart request with a stock-managed variation then makes a few more database or object-cache reads. When you return true again, maps stored before you switched them off are used again unless a change made through WordPress, or Clear cache, retired them meanwhile.

After changing translation links of attribute terms directly in the database, outside WordPress, use Clear cache: PerfLocale’s other caches need it too, and it retires every stored map whether or not this filter is set.

This filter does not switch Inventory Sync; use perflocale/woocommerce/inventory_sync_enabled for that.

Parameters
ParameterDescription
bool $enabledDefault true.
Returns
bool
Example2 lines
// Look the matching variations up on every stock check.
add_filter( 'perflocale/woocommerce/stock_map_enabled', '__return_false' );

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

Seconds PerfLocale waits after a change that retires the stored stock maps before it stores new ones.

Default 60. Floor-clamped to 60, so a filter cannot remove the wait. Raise it when database reads can lag the primary or an import keeps a transaction open longer. A very large value stops new maps from being stored; to switch them off, use perflocale/woocommerce/stock_map_enabled.

Parameters
ParameterDescription
int $secondsDefault 60.
Returns
int
Example4 lines
// Database replicas can lag by a few minutes on this host.
add_filter( 'perflocale/woocommerce/stock_map_settle_seconds', function ( int $seconds ): int {
	return 300;
} );

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

Whether to send Vary: Cookie on WooCommerce Store API responses whose language may come from the perflocale_lang cookie, that is, requests to the unprefixed /wp-json/wc/store/ base.

Off by default. Turn it on only when your page or edge cache honours Vary; otherwise bypass that path in the cache, or add the perflocale_lang cookie to its cache key (see Store API responses and caches).

Asked when a Store API response is served on a request whose URL carries no language. Responses that already carry Cache-Control: no-store, such as the cart and checkout routes, are skipped. The header is added next to any existing Vary value, so WooCommerce’s Vary: Origin stays.

Parameters
ParameterDescription
bool $varyDefault false.
WP_REST_Request $requestStore API request being served.
Returns
bool
Example2 lines
// The page cache in front of this site honours Vary.
add_filter( 'perflocale/router/store_api_vary_cookie', '__return_true' );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Filter the WooCommerce email IDs whose subjects, headings, and additional content are translatable via the String Translation system.

Parameters
ParameterDescription
array $idsArray 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
Returns
array
Example4 lines
add_filter( 'perflocale/woocommerce/translatable_email_ids', function ( array $ids ): array {
	$ids[] = 'customer_stock_notification'; // Add a custom WC email type.
	return $ids;
} );

Source src/WooCommerce/EmailTranslation.php(opens GitHub in a new tab)

Whether one WooCommerce order email renders in the order’s language rather than the shop’s.

Default is WooCommerce’s own answer, WC_Email::is_customer_email() (and true for an email object with no such method): an email addressed to the customer uses the customer’s language, one addressed to the store admin uses the shop language. Of the nine order emails PerfLocale hooks, new_order, cancelled_order and failed_order go to the store admin.

Returning false does two things: no locale is switched for the body, and the subject, heading and additional content are left as WooCommerce formatted them - the String Translation lookup is skipped too.

Parameters
ParameterDescription
bool $usesdefault: whether WooCommerce considers this a customer email.
\WC_Email $emailthe email being rendered.
\WC_Order $orderthe order it concerns.
Returns
bool
Example3 lines
add_filter( 'perflocale/woocommerce/email_uses_order_language', function ( bool $uses, $email, $order ): bool {
	return $email->id === 'new_order' ? true : $uses;
}, 10, 3 );

Source src/WooCommerce/EmailTranslation.php(opens GitHub in a new tab)

Contact Form 7 1

Mail sub-keys taken from the SOURCE form rather than from the translation.

By default a translated form owns its whole mail array, ROUTING INCLUDED - the recipient, sender and additional headers were copied when the translation was created and are never re-synced. That is deliberate, because a different recipient per language is a legitimate setup, and editing the source flips the translation to Needs update so the change is visible. Use this filter when you would rather have mail routing follow the source while the wording stays translated.

Parameters
ParameterDescription
array<int, string> $keysSub-keys of CF7’s mail/mail_2 arrays. Default [].
int $form_idSource form post ID.
int $translated_idTranslation post ID.
Returns
array
Example3 lines
add_filter( 'perflocale/cf7/source_mail_keys', function ( array $keys ): array {
	return [ 'recipient', 'additional_headers' ];
} );

Source addons/contact-form-7/PerfLocaleContactForm7.php(opens GitHub in a new tab)

Gravity Forms 1

Filter the translations applied to a Gravity Forms form for the current language, evaluated at render time.

Gravity Forms keeps forms in its own tables - a GF form id is not a wp_posts id - so per-form translations live in a per-form option (perflocale_gf_translations_{form_id}) keyed by language slug rather than in the post-based translation flow. Return an empty array to leave a form untranslated, or supply the translations array to override labels, descriptions, placeholders, choices, and confirmations programmatically without writing to the option store.

Parameters
ParameterDescription
array $translationsStored translations for this form + language (empty when none). Shape: [ 'fields' => [ field_id => [ 'label', 'description', 'placeholder', 'choices' ] ], 'confirmations' => [ id => [ 'message', 'url' ] ] ].
int $form_idGravity Forms form id.
string $language_slugCurrent PerfLocale language slug.
Returns
array
Example6 lines
add_filter( 'perflocale/gravity_forms/form_translations', function ( array $translations, int $form_id, string $language_slug ): array {
	if ( $form_id === 3 && $language_slug === 'de' ) {
		$translations['fields']['1']['label'] = 'Vollständiger Name';
	}
	return $translations;
}, 10, 3 );

Source addons/gravity-forms/PerfLocaleGravityForms.php(opens GitHub in a new tab)

GeoIP Redirect 7

Per-request override of the saved redirect priority order.

Receives the cleaned, sanitised order array - useful for forcing geo first for staff cookie holders, or falling back to browser while a third-party geo provider is rate-limited.

Parameters
ParameterDescription
array $orderSanitised priority array. Known sources: geo, browser, edge_hint (any other slug is dropped by the sanitiser). Default order: geo, browser, edge_hint.
Returns
array
Example7 lines
add_filter( 'perflocale/redirect/priority_order', function ( array $order ): array {
	if ( isset( $_COOKIE['acme_staff'] ) ) {
		// Trust GeoIP first for the team, then fall back to browser language.
		return [ 'geo', 'browser', 'edge_hint' ];
	}
	return $order;
} );

Source src/Settings.php(opens GitHub in a new tab)

Register GeoIP providers.

PerfLocale ships the redirect logic but no provider and no endpoint - the built-in provider list is empty, so the feature contacts nothing until a site wires a source through this filter or through perflocale/geo/lookup_country (the cheaper option when your host already hands you a country header).

Parameters
ParameterDescription
array $providersAssociative array of provider definitions.
Returns
array
Example12 lines
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;
} );

Source src/Router/GeoRedirect.php(opens GitHub in a new tab)

Filter the country-to-language mapping array.

Parameters
ParameterDescription
array<string, string> $mapThe resolved map: keys are uppercase ISO 3166-1 alpha-2 country codes, values are language slugs (e.g. [ 'DE' => 'de', 'AT' => 'de' ]). This is not the raw geo_country_map setting - that stores language slug => comma-separated country codes and is already expanded, uppercased and validated before this filter runs. The default language is always excluded from the map.
Returns
array<string, string>

Source src/Router/GeoRedirect.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
bool $should_redirectdefault true.
Returns
bool
Example14 lines
// 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;
} );

Source src/Router/GeoRedirect.php(opens GitHub in a new tab)

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 the default.

The filter also runs when a visitor opens a post under another language’s slug and the post has a published translation in the current language. There $from_slug and $to_slug are both the current language, and the default is 301: the visitor goes to the translation in one permanent redirect.

Parameters
ParameterDescription
int $statusdefault 302; 301 when redirecting to the current language’s own translation.
string $from_slugcurrent (missing) language slug.
string $to_slugresolved fallback language slug.
int $from_post_idsource post being rendered.
int $to_post_idtarget post ID (0 when redirecting to the language homepage).
Returns
int
Example4 lines
// Permanent redirects for en_US → en_GB specifically.
add_filter( 'perflocale/fallback/redirect_status', function ( int $status, string $from, string $to ): int {
	return ( $from === 'en-us' && $to === 'en-gb' ) ? 301 : $status;
}, 10, 3 );

Source src/Translation/PostQueryFilter.php(opens GitHub in a new tab)

Privacy 3

PerfLocale skips writing the perflocale_lang cookie on a visitor’s first request when the value would carry no information: WooCommerce alone makes the cookie useful on the site, the request resolves to the default language anyway, and no edge worker is steering.

Skipping it means those pages carry no Set-Cookie, so a full-page cache in front of the site can store them. Return false to write the cookie in that case too.

Nothing the plugin does depends on that cookie being present for a default-language visitor: the cart, checkout and order emails all fall back to the default language, which is the same answer the cookie would have given. Override this only if something of your own - an edge rule, an analytics script, a CDN cache key - reads perflocale_lang on default-language pages and needs it there.

Translated pages always write the cookie: the WooCommerce Store API posts to an address with no language in it and resolves the shopper’s language from this cookie, so a non-default shopper must carry one.

Parameters
ParameterDescription
bool $skipWhether to skip the redundant write.
string $slugThe language slug that would have been written.
Returns
bool
Example2 lines
// Write the cookie on first default-language visits as well.
add_filter( 'perflocale/cookie/skip_redundant_write', '__return_false' );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Whether to write the perflocale_lang cookie on this request.

Use it for decisions the Cookieless mode setting cannot make, such as following one preference category of a consent plugin. It is asked only right before the cookie would be written, so a request that writes nothing never reaches it: a bot, perflocale/privacy/consent_given returning false, headers already sent, an unknown slug, a visitor who already holds this value, no feature that reads the cookie, or a skipped redundant write. It cannot force a write in any of those cases, and its answer does not change whether a visitor is redirected.

The filter wins over the Cookieless-mode setting in both directions: true writes the cookie even in Cookieless mode, and false blocks it while the setting allows it.

In Cookieless mode, hooking this filter also keeps the automatic first-visit redirects (browser language, GeoIP, edge hint) running. With Cookieless mode on and nothing hooked here, the cookie can never be written, so those redirects do not run: without the cookie nothing marks a visitor as returning, and every default-language page would redirect them again.

Parameters
ParameterDescription
bool $enabledDefault true; false when Cookieless mode is on.
string $slugLanguage slug about to be written.
Returns
bool
Example5 lines
// Write the language cookie only after "preferences" consent.
// my_consent_for() stands for your consent plugin's own check.
add_filter( 'perflocale/language_cookie/enabled', function ( bool $enabled, string $slug ): bool {
	return $enabled && my_consent_for( 'preferences' );
}, 10, 2 );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Gate for PerfLocale’s perflocale_lang cookie and its automatic GeoIP + browser-language redirects.

Return false to suppress the cookie and both redirects until the visitor has granted consent. Default true - the cookie and both redirects run immediately. (To drop the cookie unconditionally without a consent plugin, use the Cookieless-mode setting instead.)

Parameters
ParameterDescription
bool $granteddefault true.
Returns
bool
Example10 lines
// Hook any consent-management plugin (Cookiebot, Complianz, Iubenda,
// OneTrust...) that exposes a "consent given for marketing/functional
// cookies" check.
add_filter( 'perflocale/privacy/consent_given', function (): bool {
	if ( ! function_exists( 'complianz_has_consent' ) ) {
		return true; // Consent plugin not active - behave as before.
	}

	return (bool) complianz_has_consent( 'functional' );
} );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab) src/Router/GeoRedirect.php(opens GitHub in a new tab)

REST API 3

Filter the JSON payload returned by GET /perflocale/v1/config before it is cached and served to edge runtimes (Cloudflare Worker, Vercel Edge, Netlify Edge, …).

Use it to carry feature flags, per-language A/B variants, fallback chains, or any routing metadata the edge needs without an extra origin round-trip.

The result is stored in the 3-layer cache and contributes to the ETag. Keep additions deterministic (no timestamps, no request-specific data). If the custom fields change at runtime, call \PerfLocale\Api\ConfigController::invalidate() or update any option/setting that already triggers perflocale/settings/updated.

Parameters
ParameterDescription
array $payloadThe full config payload (version, url_mode, url_prefix_type, default_slug, hide_default_prefix, excluded_paths, detection_order, edge_hint_header, edge_hint_cookie, languages).
Returns
array
Example12 lines
// 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;
} );

Source src/Api/ConfigController.php(opens GitHub in a new tab)

Whether the list of active languages may be read anonymously: GET /perflocale/v1/languages, GET /perflocale/v1/languages/{slug} and the perflocale/list-languages ability (which stays listed and answers only logged-in users when false).

Default true - the payload is a strict subset of what is already rendered to visitors (switcher, hreflang tags, URL structure), so there is nothing private to protect. Return false to require the read capability (any logged-in user) instead. Write endpoints are unaffected - they always require perflocale_manage_languages.

Parameters
ParameterDescription
bool $publicdefault true.
WP_REST_Request $requestcurrent request.
Returns
bool
Example2 lines
// Block anonymous reads of /languages.
add_filter( 'perflocale/api/languages_public', '__return_false' );

Source src/Api/LanguagesController.php(opens GitHub in a new tab)

Gate for the public read endpoint GET /wp-json/perflocale/v1/config (consumed by the Cloudflare Worker / Vercel Edge / Netlify Edge integrations).

Default true - public-read, because the payload is a strict subset of what is already rendered to visitors (URL mode, default slug, active languages, edge-hint header / cookie names). Return false or a WP_Error to require authentication; return true to keep public-read. Symmetric with perflocale/api/languages_public for the /languages endpoint - write endpoints are unaffected and always require perflocale_manage_languages.

If the filter returns anything other than bool / WP_Error, PerfLocale emits a _doing_it_wrong() notice (dev-mode) and falls back to the default public-read behaviour rather than crashing the endpoint.

While anything hooks this filter, and for any logged-in caller, the endpoint answers with Cache-Control: private, no-store instead of the public cache headers, so a shared cache never stores a response the gate might refuse to another client.

Parameters
ParameterDescription
bool|WP_Error $alloweddefault true.
WP_REST_Request $requestcurrent request.
Returns
bool|WP_Error
Example11 lines
// Require admin auth to fetch the edge config.
add_filter( 'perflocale/edge_worker/config_permission_callback', static fn() => current_user_can( 'manage_options' ) );

// Allow internal monitoring (header-presented bearer) but block everyone else.
add_filter( 'perflocale/edge_worker/config_permission_callback', function ( $allowed, \WP_REST_Request $request ) {
	$bearer = $request->get_header( 'authorization' );
	if ( $bearer && hash_equals( 'Bearer ' . MY_EDGE_TOKEN, $bearer ) ) {
		return true;
	}
	return new \WP_Error( 'rest_forbidden', 'Edge config requires auth on this host.', [ 'status' => 401 ] );
}, 10, 2 );

Source src/Api/ConfigController.php(opens GitHub in a new tab)

Admin 5

Whether the post-edit Categories / Tags checklist should be filtered to the current language.

Default true when 2+ languages are active. Return false to show every term across every language in the metabox (e.g. for a custom integration that re-scopes terms in JS).

Parameters
ParameterDescription
bool $enabled-
int $post_id-
array $args-
Returns
bool
Example4 lines
add_filter( 'perflocale/admin/filter_terms_checklist', function ( bool $enabled, int $post_id, array $args ): bool {
	// Don't filter for editors managing the term taxonomy itself.
	return current_user_can( 'manage_categories' ) ? false : $enabled;
}, 10, 3 );

Source src/Admin/MetaBox.php(opens GitHub in a new tab)

Filter the bundled list of predefined languages shown in the PerfLocale → Languages → Add New quick-select.

The plugin ships with 190+ entries covering every major locale plus regional variants (en-US, en-GB, fr-CA, ar-EG, etc.); use this filter to add custom languages (constructed languages, internal locale variants, niche dialects), prune the bundled set, or replace it entirely with a curated short-list for site editors.

Each entry must be an associative array with these keys:

  • slug - URL slug. Must match /^[a-z]{2,3}(?:-[a-z]{2,3})?$/ - two or three lowercase letters, optionally followed by a hyphen and two or three more (de, fil, pt-br). No digits, underscores or longer subtags. Must be unique. The shape is enforced both when a language is created and when it is renamed.
  • locale - WordPress locale (e.g. en_US, fr_FR). Maximum 20 characters, must be unique.
  • name - English display name shown in the admin picker.
  • native_name - Native-script display name shown alongside the English name.
  • flag - ISO 3166-1 alpha-2 country code (lowercase, e.g. us, fr); used to render the flag emoji. Empty string is allowed for languages without an obvious flag.
  • text_direction - 'ltr' or 'rtl'.
  • date_format - PHP date() format string. Often locale-specific (e.g. 'j F Y' for European day-month-year).
  • time_format - PHP date() format string (e.g. 'g:i a', 'H:i').

Notes: This filter only affects the admin quick-select shown when adding a new language. Once a language is saved into wp_perflocale_languages, the filter no longer touches it - existing rows are managed via the regular admin Edit screen or the REST API. Removing an entry from the filter does not remove already-added languages.

Parameters
ParameterDescription
array<int, array<string, string>> $predefinedBundled languages as loaded from data/languages.php (190+ entries).
Returns
array
Add a custom language:13 lines
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):7 lines
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):8 lines
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;
} );

Source src/Admin/Pages/LanguagesPage.php(opens GitHub in a new tab)

Change, reorder or drop the per-language rows of the Translations panel before it renders.

One renderer draws the server-rendered panel - the classic editor meta box, and any host plugin’s own editor screen an add-on mounts it on (Contact Form 7 and WPForms both do) - so a listener here reaches all of those. It does not reach the block editor or Site Editor sidebars: those are React panels fed by the perflocale/v1/translations/post/<id> REST route, which builds its rows independently and applies neither panel filter. Each row carries slug, badge, native_name, is_current, has_translation, translated_id, source_id, source_status and edit_url.

Whether an untranslated row shows a working + Create link is not a row key. PerfLocale decides it once per panel, outside the rows, from the user’s perflocale_translate capability and the post type’s own create capability (see perflocale/object/user_can); a user without both sees a disabled pill instead. A listener here cannot turn the link back on.

The edit_url key is the reason to reach for this filter rather than perflocale/admin/edit_post_link: that one is keyed on post type alone, so it cannot send the same type to different places on different screens. Because $args carries the rendering context, a builder that mounts the panel inside its own editor can keep “Edit” in that editor while the Translations screen still links to the WordPress editor. Returning an empty array suppresses the panel entirely.

Parameters
ParameterDescription
array<int, array<string, mixed>> $rowsOne entry per active language.
int $post_idThe object being described.
array<string, mixed> $argsRender arguments, including context.
Returns
array
Example13 lines
add_filter( 'perflocale/panel/rows', function ( array $rows, int $post_id, array $args ): array {
	if ( ( $args['context'] ?? '' ) !== 'my-builder' ) {
		return $rows; // Leave every other screen alone.
	}

	foreach ( $rows as &$row ) {
		if ( ! empty( $row['has_translation'] ) ) {
			$row['edit_url'] = my_builder_edit_url( (int) $row['translated_id'] );
		}
	}

	return $rows;
}, 10, 3 );

Source src/Admin/TranslationsPanel.php(opens GitHub in a new tab)

Wrap, replace or suppress the finished Translations panel markup.

Runs on the completed HTML string, after perflocale/panel/rows, and reaches the same server-rendered surfaces that filter does - not the block editor or Site Editor sidebars. Reach for perflocale/panel/rows first when you only want to change what the rows say - it keeps PerfLocale’s escaping; use this one when you need to change the markup itself.

⚠️ Wherever PerfLocale echoes the panel, what you return is passed through wp_kses_post() first, so escape your own dynamic values and expect anything outside that allowlist to be removed. Some host screens narrow it further by filtering their whole page through wp_kses() - Contact Form 7 does - so on those screens only that host’s allowlist survives. That pass happens when the panel is echoed, not inside this filter: TranslationsPanel::get_html() returns the markup as your listener left it, so code that fetches it that way must run it through wp_kses_post() before output. Code that hands the string to something that compiles it as an Underscore template (wp.template(), some page-builder controls) must also encode { and }, which kses leaves alone.

Parameters
ParameterDescription
string $htmlThe panel markup.
int $post_idThe object being described.
array<string, mixed> $argsRender arguments, including context.
Returns
string
Example7 lines
add_filter( 'perflocale/panel/render', function ( string $html, int $post_id, array $args ): string {
	if ( $html === '' ) {
		return $html; // Nothing rendered; do not invent chrome.
	}

	return $html . '<p class="description">' . esc_html__( 'Ask the content team before translating.', 'my-plugin' ) . '</p>';
}, 10, 3 );

Source src/Admin/TranslationsPanel.php(opens GitHub in a new tab)

Roles & Permissions 5

Filter the capabilities granted to the Editor role on plugin activation.

Return an empty array to prevent Editors from receiving any PerfLocale capabilities. Return a subset to restrict them to specific ones. The Administrator role is not affected.

Note: Capability grants are stored in the database and only written once per version. To re-apply the filter to an existing install, reset the version flag: delete_option( 'perflocale_caps_version' ); then reload any admin page.

Parameters
ParameterDescription
array<string, bool> $capsMap of capability => grant. Default: perflocale_translate, perflocale_manage_translations, perflocale_approve_translations, perflocale_use_mt.
Returns
array
Example10 lines
// Remove ALL PerfLocale capabilities from the Editor role.
add_filter( 'perflocale/roles/editor_caps', '__return_empty_array' );

// Restrict Editors to translation-only access (no management capabilities).
add_filter( 'perflocale/roles/editor_caps', function ( array $caps ): array {
	return [
		'perflocale_translate' => true,
		'perflocale_use_mt' => true,
	];
} );

Source src/Admin/TranslatorRole.php(opens GitHub in a new tab)

Filter the default capabilities of the Translator role.

Keys are capability names, values grant (true) or deny (false). A capability added here is granted on the next rebuild of the role; one taken out here is removed on the next rebuild while the role still grants it. Changes a site owner made to the role by hand are kept: a capability the owner removed or denied is not given back, also across deactivation and reactivation, and one the owner added stays.

A rebuild runs when the stored capability version (perflocale_caps_version) is behind the plugin’s: after a plugin update that changes capabilities, on activation, or after that option is deleted. To apply the filter on an existing install, run delete_option( 'perflocale_caps_version' ); once and load any admin page.

Parameters
ParameterDescription
array<string, bool> $capsMap of capability => grant. Default: read, edit_posts, edit_others_posts, edit_published_posts, edit_pages, edit_others_pages, edit_published_pages, upload_files, perflocale_translate, perflocale_use_mt.
Returns
array
Example5 lines
// Translators edit only their own posts and pages.
add_filter( 'perflocale/roles/translator_caps', function ( array $caps ): array {
	unset( $caps['edit_others_posts'], $caps['edit_others_pages'] );
	return $caps;
} );

Source src/Admin/TranslatorRole.php(opens GitHub in a new tab)

Filter the PerfLocale capabilities granted to WooCommerce’s Shop Manager role.

On a site where WooCommerce is loaded and the WooCommerce add-on is not disabled, the role gets these capabilities once, on activation or on the next admin page load, so shop managers can create product translations. What was granted is recorded in the perflocale_shop_manager_caps option, and the grant is not repeated while that record exists, so a capability the site owner removes stays removed. A capability the role already has an entry for, granted or denied, is never changed. Deactivating PerfLocale takes the recorded capabilities off the role, and reactivating puts back those the owner had not removed.

Return an empty array to grant nothing. To run the grant again after changing the filter, delete the perflocale_shop_manager_caps option.

Parameters
ParameterDescription
array<string, bool> $capsMap of capability => grant. Default: perflocale_translate (machine translation, perflocale_use_mt, is not granted).
Returns
array<string, bool>
Example2 lines
// Do not give shop managers any PerfLocale capability.
add_filter( 'perflocale/roles/shop_manager_caps', '__return_empty_array' );

Source src/Admin/TranslatorRole.php(opens GitHub in a new tab)

Filter which WordPress roles have PerfLocale capabilities removed on plugin deactivation or uninstall.

Fires in two places: TranslatorRole::remove_roles() (deactivation) and SiteCleanup::strip_role_and_caps(), which uninstall.php reaches through SiteCleanup::purge_current_site() on both the full-wipe and preserve-data branches.

Parameters
ParameterDescription
string[] $rolesArray of role slugs. Default: ['administrator', 'editor'] on deactivation; ['editor', 'administrator'] on a keep-data uninstall; every role registered on the site (the keys of wp_roles()->roles) on a delete-data uninstall.
Returns
array
Example10 lines
// Do not strip caps from the Editor role on deactivation/uninstall.
add_filter( 'perflocale/roles/cap_roles', function ( array $roles ): array {
	return array_diff( $roles, [ 'editor' ] );
} );

// Also clean up a custom role that was granted caps programmatically.
add_filter( 'perflocale/roles/cap_roles', function ( array $roles ): array {
	$roles[] = 'shop_manager';
	return $roles;
} );

Source src/Admin/TranslatorRole.php(opens GitHub in a new tab) src/Database/SiteCleanup.php(opens GitHub in a new tab)

Decide whether the current user may act on one translatable object, when WordPress cannot answer the question itself.

A post type registered with map_meta_cap => false gets no meta-capability mapping, so current_user_can( 'edit_post', $id ) resolves to the type’s primitive capability - and if the host plugin never grants that capability to any role, the answer is false for everybody, administrators included. That is not a permissions failure; the question simply has no answer in core’s model, and only the host knows the real one.

WPForms is exactly that shape, which is why the bundled WPForms addon answers here using WPForms’ own access object rather than inventing a capability on its behalf - a site running WPForms’ Access addon keeps its granular per-form rules. It answers read with the same per-form edit rule as edit, and create with WPForms’ own form-creation capability (create_forms), which WPForms checks without a form ID. Contact Form 7 registers its type the same way but does add the mapping filter, so it needs nothing.

PerfLocale asks with one of four actions. $can is always core’s answer for the matching capability, and $post_id is always an existing post.

  • edit - may the user edit this object. Asked about a post before PerfLocale lists its translations or links to them, sets its language or creates a translation of it (the Translations panel, the REST routes and the admin Create action); about a translation before a REST update changes it; and, together with read, about the original a new translation is copied from.
  • delete - may the user delete this translation. Asked by the REST delete route, and by an update that moves a translation to the trash.
  • read - asked together with edit about the post a new translation would be copied from, when that is not the post the request named. A new translation is copied from the group’s default-language post when it has one, and both answers must be yes. $can is core’s read_post.
  • create - may the user create a new post of this post’s type. $post_id is an existing post, never the post that will be created. On the paths that create a translation it is the post the translation would be copied from; in the Translations panel it is the post being listed. $can is core’s answer for the type’s create_posts capability (for a pattern, the wp_block type, that is publish_posts). The paths that create ask it only when a translation would actually be created: the REST create route, the admin Create action, the machine-translation routes, the abilities and XLIFF import (see Permissions). The Translations panel asks it about the post it lists, and only for a user who holds perflocale_translate, to decide whether to show a working Create link. A type whose create_posts is not a capability name (Pods sets it to false when a type’s Add New is disabled) is refused without asking this filter.

⚠️ Returning true grants access, so answer only for post types you own, and return $can untouched for everything else. The bundled addons are consulted only when core has already said no, so they can widen access for their own type and can never revoke access core granted. What a widened answer opens depends on who is asking:

  • The Translations panel requires no PerfLocale capability, so a widened edit shows that object’s translation list and edit links. It offers Create only to a user who also holds perflocale_translate.
  • The Translations REST routes and the admin Create action require perflocale_translate before asking, so a listener cannot hand translation rights to a user who has none.
  • The machine-translation routes, the translate-post ability and the bulk translation job (perflocale_use_mt), XLIFF import (perflocale_import_export) and the create-translation ability (perflocale_translate) also check core edit_post on the post they name before asking, so a listener cannot open them to a user core does not already let edit that post.

Answer create with the host’s rule for creating, not its rule for editing: a user who may edit one form is not necessarily allowed to create forms. Answering read with the edit rule is fine, because edit is asked about the same post and must also be yes.

A listener that sends every action other than delete to its edit rule also answers create with that rule, so give create its own answer. A listener that returns $can for actions it does not know leaves read and create to core. On a type whose capabilities no role holds, the case this filter exists for, core refuses both, so the paths that ask them refuse to create translations of that type until the listener answers them: the paths listed under create above, and the bulk translation job when a translation would be copied from a post other than the one it was given.

Parameters
ParameterDescription
bool $canWhether core’s capability check passed.
string $actionedit, delete, read or create.
int $post_idThe object being acted on (for create, an existing post whose type is asked about; see above).
string $post_typeIts post type.
Returns
bool
Example14 lines
add_filter( 'perflocale/object/user_can', function ( bool $can, string $action, int $post_id, string $post_type ): bool {
	if ( $can || 'my_form' !== $post_type ) {
		return $can; // Never narrow, and never answer for someone else's type.
	}

	// Defer to the host's own authority, not to a capability we made up.
	return match ( $action ) {
		'edit', 'read' => my_plugin_user_can( 'edit_form', $post_id ),
		'delete'       => my_plugin_user_can( 'delete_form', $post_id ),
		// "May create a form at all": $post_id is an existing form, not the new one.
		'create'       => my_plugin_user_can( 'create_forms' ),
		default        => $can, // An action you do not know yet stays with core.
	};
}, 10, 4 );

Source src/Helper.php(opens GitHub in a new tab)

Performance & Caching 2

Filter the maximum number of translation-link rows held in the autoloaded eager-link-map option (perflocale_eager_links_post / perflocale_eager_links_term).

When the per-type link count is at or below this cap, every prime_translations() call is served from alloptions with zero queries. Past the cap, the map is replaced with a 'too_large' sentinel and the plugin falls back to the per-key cascade in CacheManager - on those sites a persistent object cache (Redis / Memcached) is what keeps the hot path fast.

Default 2000. Lower on memory-constrained hosts where a large alloptions blob hurts more than the extra DB round-trips. Raise on memory-rich hosts that prefer a single autoloaded option over hitting the cache cascade at scale. Receives the ObjectType enum as the second arg so post and term maps can be tuned independently.

Parameters
ParameterDescription
int $cap(default 2000)
\PerfLocale\Enum\ObjectType $type-
Returns
int
Example4 lines
add_filter( 'perflocale/cache/eager_map_row_cap', function ( int $cap, \PerfLocale\Enum\ObjectType $type ): int {
	// Roomy host: raise the post cap to 5000, keep the term cap at default.
	return $type === \PerfLocale\Enum\ObjectType::Post ? 5000 : $cap;
}, 10, 2 );

Source src/Database/Repository/TranslationGroupRepository.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
int $cap(default 768000)
\PerfLocale\Enum\ObjectType $type-
Returns
int
Example1 line
add_filter( 'perflocale/cache/eager_map_byte_cap', static fn (): int => 1024 * 1024 ); // 1 MB ceiling

Source src/Database/Repository/TranslationGroupRepository.php(opens GitHub in a new tab)

Background Jobs 36

Filters that tune the background-processing system. See the Background Jobs doc for the full feature reference.

Per-type override of the Auto-mode threshold.

When args_size() for a dispatch is at or above this threshold, the job goes async; below, it runs inline. Defaults per job type: 1000 for string_scan and data_import, 500 for wpml_migration / polylang_migration, 200 for translatepress_migration, 5000 for data_export, 50 for bulk_string_translate, 25 for bulk_translate, 1 for site_translate.

Parameters
ParameterDescription
int $base(resolved from settings or default)
array $args(the dispatch args).
Returns
int
Example8 lines
// Force string scans to ALWAYS run async, regardless of file count.
add_filter( 'perflocale/jobs/threshold/string_scan', static fn(): int => 0 );

// Dynamic threshold for data imports: queue big files, inline small ones.
add_filter( 'perflocale/jobs/threshold/data_import', static function ( int $base, array $args ): int {
	$file = $args['file_path'] ?? '';
	return file_exists( $file ) && filesize( $file ) > 5 * MB_IN_BYTES ? 0 : 999999;
}, 10, 2 );

Source src/Background/AbstractJob.php(opens GitHub in a new tab)

Whether an identical job already queued or running blocks a new dispatch.

Default true. “Identical” means the same job type, byte-identical arguments and the same site - the logical operation, not merely the type - so unrelated work of the same type still runs in parallel, and a chunked chain (whose cursor advances on every link) is never mistaken for a repeat of itself. When a twin is found, Dispatcher::enqueue() returns that job’s ID with duplicate => true instead of queueing a second one. A twin whose worker stopped without finishing is marked failed instead, and the new dispatch replaces it.

Parameters
ParameterDescription
bool $enabled-
string $type-
array $args-
Returns
bool
Example4 lines
// Let a custom job type be dispatched repeatedly with the same args.
add_filter( 'perflocale/jobs/deduplicate_admission', static function ( bool $on, string $type ): bool {
	return 'my_addon_reindex' === $type ? false : $on;
}, 10, 2 );

Source src/Background/Dispatcher.php(opens GitHub in a new tab)

Maximum array-nesting depth of the args payload accepted by Dispatcher::enqueue().

Default 20. A payload can fit under the byte cap (max_args_bytes) yet still be pathologically deep - deeply-nested arrays trip PHP’s serializer (which WP uses to write option_value) and can blow xdebug.max_nesting_level. Args deeper than this are rejected before serialisation. Floor-clamped to 4, so any legitimate job-args shape is safe.

Parameters
ParameterDescription
int $depth(default 20, clamped to a minimum of 4).
Returns
int
Example2 lines
// A custom job that ships a genuinely nested config tree.
add_filter( 'perflocale/jobs/max_args_depth', static fn(): int => 40 );

Source src/Background/Dispatcher.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
int $max(default 50, clamped to 10 minimum).
Returns
int
Example2 lines
// Sites with very high dispatch throughput can show more history.
add_filter( 'perflocale/jobs/active_index_max', static fn(): int => 250 );

Source src/Background/JobState.php(opens GitHub in a new tab)

Upper bound on how many queued/running jobs the Resumer re-dispatches in one sweep (e.g. after a deactivate/reactivate).

Default 1000 - far above the active-display cap so pending jobs aren’t silently dropped, yet bounded so a runaway jobs table can’t OOM the sweep. Floor-clamped to 1.

Parameters
ParameterDescription
int $max(default 1000, clamped to 1 minimum).
Returns
int
Example1 line
add_filter( 'perflocale/jobs/resume_max', static fn(): int => 5000 );

Source src/Background/JobState.php(opens GitHub in a new tab)

Maximum size of the uploaded JSON import envelope.

Default 536870912 (512 MB). The Export & Import screen sends a file larger than PHP’s upload limits (upload_max_filesize, post_max_size) in pieces, so this filter is the screen’s only size cap. wp perflocale import does not apply it.

Parameters
ParameterDescription
int $bytes-
Returns
int
Example1 line
add_filter( 'perflocale/import/max_file_bytes', static fn(): int => 200 * 1024 * 1024 ); // 200 MB

Source src/Admin/AdminController.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
int $size-
string $table-
Returns
int
Example3 lines
add_filter( 'perflocale/export/batch_size', static function ( int $size, string $table ): int {
	return $table === 'strings' ? 5000 : 1000;
}, 10, 2 );

Source src/Admin/DataExporter.php(opens GitHub in a new tab)

Translation groups (trid values) fetched per SELECT during the WPML migration.

The importer first pulls every distinct trid for posts then for terms (cheap, one BIGINT per row), chunks them by this filter, and only fetches the full (trid, element_id, language_code) rows one batch at a time. Lowering the value reduces peak memory on very large sites at the cost of more SQL roundtrips. Default 100. Clamped to 10-1000.

Parameters
ParameterDescription
int $size-
Returns
int
Example1 line
add_filter( 'perflocale/migration/wpml/batch_size', static fn(): int => 250 );

Source src/Migration/WpmlImporter.php(opens GitHub in a new tab) src/Admin/SiteHealth.php(opens GitHub in a new tab)

Number of post_translations / term_translations taxonomy terms fetched per SELECT during the Polylang migration.

The importer first lists every term_id (cheap), chunks them by this filter, and only then fetches the description payload (a serialized PHP array that maybe_unserialize expands into N slots in memory per row). Lowering the value reduces peak memory on very large sites at the cost of more SQL roundtrips. Default 100. Clamped to 10-1000.

Parameters
ParameterDescription
int $size-
Returns
int
Example1 line
add_filter( 'perflocale/migration/polylang/batch_size', static fn(): int => 250 );

Source src/Migration/PolylangImporter.php(opens GitHub in a new tab) src/Admin/SiteHealth.php(opens GitHub in a new tab)

Rows fetched per keyset-paginated batch when importing string translations from WPML's icl_string_translations table.

The importer streams via WHERE s.id > $last_id ORDER BY s.id ASC LIMIT N so peak memory stays bounded at one batch regardless of total row count. Lowering the value trades fewer SQL roundtrips for smaller memory peaks; raising it does the opposite. Default 500. Floor-clamped to 1 (anything lower reverts to the default).

Parameters
ParameterDescription
int $size-
Returns
int
Example1 line
add_filter( 'perflocale/migration/wpml_string_batch_size', static fn(): int => 1000 );

Source src/Migration/WpmlImporter.php(opens GitHub in a new tab)

WPML element types (icl_translations.element_type) the WPML import leaves out.

Each entry is a post_<post type> or tax_<taxonomy> value. The default skips menu items and attachments, which PerfLocale does not group into translation sets, and two internal taxonomies of WPML and WooCommerce. Return an empty array to import every type. Entries of any other shape are dropped.

Parameters
ParameterDescription
string[] $typesdefault [ 'post_nav_menu_item', 'post_attachment', 'tax_translation_priority', 'tax_product_visibility' ].
Returns
array
Example5 lines
// Also leave out a custom post type WPML tracked.
add_filter( 'perflocale/migration/wpml/skipped_element_types', static function ( array $types ): array {
	$types[] = 'post_acme_log';
	return $types;
} );

Source src/Migration/WpmlImporter.php(opens GitHub in a new tab)

Rows fetched per keyset-paginated batch when importing gettext string translations from TranslatePress's per-language trp_gettext_* tables.

Streams via WHERE g.original_id > $last_id ORDER BY g.original_id ASC LIMIT N so every row is imported however many gettext strings a language has; lowering the batch size reduces memory at the cost of more SQL roundtrips. Default 1000. Values below 100 revert to the default.

Parameters
ParameterDescription
int $size-
Returns
int
Example1 line
add_filter( 'perflocale/migration/translatepress/gettext_batch_size', static fn(): int => 2500 );

Source src/Migration/TranslatePressImporter.php(opens GitHub in a new tab)

Minimum TranslatePress translation status to import from its dictionary tables.

Default 1 (machine-translated) so the migration faithfully reproduces what the source site actually displayed; return 2 to import only human-reviewed translations.

Parameters
ParameterDescription
int $statusDefault 1 (machine-translated). Set to 2 for reviewed-only.
Returns
int
Example2 lines
// Import only human-reviewed TranslatePress translations.
add_filter( 'perflocale/migration/translatepress/min_status', static fn(): int => 2 );

Source src/Migration/TranslatePressImporter.php(opens GitHub in a new tab)

Whether the TranslatePress import also takes gettext rows that TranslatePress copied from a similar string’s translation (status 3) or from the language pack (status 4).

Default false: only TranslatePress’s own gettext translations (statuses 1 and 2) are imported, so the language pack’s text is not frozen into string overrides.

Parameters
ParameterDescription
bool $includedefault false.
Returns
bool
Example1 line
add_filter( 'perflocale/migration/translatepress/include_gettext_copies', '__return_true' );

Source src/Migration/TranslatePressImporter.php(opens GitHub in a new tab)

Strings buffered in memory before flushing to the DB via bulk_insert.

Default 500. Clamped to 50-5000. A $batch_size passed to StringScanner::scan() is only the filter’s default value - a registered callback overrides it for every caller, including the StringScan background job (which always passes an explicit size: 500 unless its batch_size job arg says otherwise).

Parameters
ParameterDescription
int $size-
Returns
int
Example1 line
add_filter( 'perflocale/strings/scanner/batch_size', static fn(): int => 2000 );

Source src/Strings/StringScanner.php(opens GitHub in a new tab)

How long a job may sit in queued or running without its updated_at being bumped before the daily GC or the stuck-job watchdog declares it stuck and marks it failed.

Default 6 hours. The resume sweep uses the same window: its follow-up check for a job whose worker lock was still held is scheduled no later than this, and it stops re-checking a job that has sat that long, leaving it to the watchdog.

Parameters
ParameterDescription
int $seconds-
Returns
int

Source src/Background/JobState.php(opens GitHub in a new tab) src/Background/Resumer.php(opens GitHub in a new tab)

How long (seconds) a worker caches the queue’s paused/unpaused state in-process before re-reading it.

Default 10. A chatty job emitting hundreds of progress ticks per second would otherwise force a full alloptions reload on every tick just to check the pause flag; this throttle window bounds that to one re-read per window. Lower it for faster pause response at the cost of more option reads; raise it to cut reads further. Floor-clamped to 1. The cache is keyed per blog, so a worker serving multiple blogs on multisite never returns another blog’s pause state.

Parameters
ParameterDescription
int $seconds(default 10, minimum 1).
Returns
int

Source src/Background/WorkerRegistry.php(opens GitHub in a new tab)

Upper bound on the delay between type-busy retries.

When the per-type concurrency lock is contended, the re-queue delay grows exponentially with jitter (starting from type_busy_retry_seconds); this caps it so a long-contended type can’t back off to an absurd delay. Default 600s (10 min). This is the per-type analogue of lock_busy_max_seconds, which caps the per-job lock backoff.

Parameters
ParameterDescription
int $seconds(default 600).
Returns
int
Example1 line
add_filter( 'perflocale/jobs/type_busy_max_seconds', static fn(): int => 1800 ); // 30 min cap

Source src/Background/WorkerRunner.php(opens GitHub in a new tab)

Maximum number of times a worker will re-queue itself when the per-job concurrency lock is held by another worker.

After this many attempts the job is marked failed with a diagnostic message; the operator can manually retry from the Jobs admin page. Distinct from the type-busy retry above - this one trips when the same job_id is being processed by a sibling worker (typically a leaked lock row from a crashed worker that never released). Default 20.

Parameters
ParameterDescription
int $max-
Returns
int
Example2 lines
// Tighter cap for tenants where a wedged lock should escalate to ops faster.
add_filter( 'perflocale/jobs/lock_busy_max_retries', static fn(): int => 5 );

Source src/Background/WorkerRunner.php(opens GitHub in a new tab)

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.

Dev-mode guard: a return that is neither null nor a JobRunnerInterface instance triggers _doing_it_wrong() and the factory falls back to the engine setting / Action Scheduler auto-detect.

Parameters
ParameterDescription
JobRunnerInterface|null $override-
Returns
JobRunnerInterface|null
Example3 lines
add_filter( 'perflocale/jobs/runner', static function ( $default ) {
	return new MyCustomQueueRunner();
}, 99 );

Source src/Background/JobRunnerFactory.php(opens GitHub in a new tab)

Veto a job dispatch before it runs - the kill switch the per-type capability check can't express on its own.

Returning false blocks the dispatch entirely (sync or async); the caller receives ['mode' => 'denied', 'error' => ...]. Returning a string vetoes with that string as the human-readable error message. Returning true (the default) lets the dispatch proceed unchanged. Distinct from perflocale/jobs/threshold/<type>, which only decides sync-vs-async - should_dispatch is yes/no.

Parameters
ParameterDescription
bool $proceeddefault true.
AbstractJob $jobthe job instance about to dispatch (call $job->get_type() to branch by type).
array $argsthe 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.
Example19 lines
// 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 );

Source src/Background/Dispatcher.php(opens GitHub in a new tab)

The wall-clock budget, in seconds, for one chunk of a site-wide translation run.

When the budget is spent the job saves its position and re-queues itself, so a large site is translated across many short requests instead of one that hits the PHP time limit. Default 20. Lower it on hosts with a tight max_execution_time; raising it buys fewer round-trips at the risk of a killed request, which costs more than it saves.

Parameters
ParameterDescription
int $secondsDefault 20; the caller casts the return to float.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/chunk_seconds', function (): int {
	return 10; // Shared host with a 30-second ceiling.
} );

Source src/Background/Jobs/SiteTranslateJob.php(opens GitHub in a new tab)

The maximum number of post/language pairs one chunk will process, regardless of how much of the time budget is left.

It is the belt to perflocale/jobs/chunk_seconds’ braces: on a fast local database the time budget alone would let a single chunk accumulate a very large batch in memory. Default 500, floored at 1.

Parameters
ParameterDescription
int $pairsDefault 500.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/chunk_max_pairs', function (): int {
	return 100; // Smaller batches, lower peak memory.
} );

Source src/Background/Jobs/SiteTranslateJob.php(opens GitHub in a new tab)

The wall-clock budget, in seconds, of one slice of a background bulk translation job (bulk_translate).

When the background worker runs the job, a slice stops before its next source-target pair once it has run at least one pair and spent this budget or reached perflocale/jobs/bulk_translate/slice_max_pairs. The job then stores its progress, and the next worker run continues the same job from there, so a host that stops long requests does not stop the job part-way. Default 20 when the worker runs in a web request (WP-Cron or Action Scheduler) and 0 when it runs in a WP-CLI process; 0 or less means no time budget. A bulk translation small enough to run inline is not split, and the site-wide job has its own chunk budget (perflocale/jobs/chunk_seconds).

Parameters
ParameterDescription
float $secondsDefault 20 in a web request, 0 under WP-CLI; the return value is cast to float.
Returns
float
Example3 lines
add_filter( 'perflocale/jobs/bulk_translate/slice_seconds', function (): float {
	return 10.0; // Host that stops requests after 15 seconds.
} );

Source src/Background/Jobs/BulkTranslateJob.php(opens GitHub in a new tab)

The most source-target pairs one slice of a background bulk translation job runs, a backstop for pairs so fast that the time budget of perflocale/jobs/bulk_translate/slice_seconds never binds.

Default 500 in a web request and 0 under WP-CLI; 0 or less means no pair cap. Under WP-CLI, where a slice has no budget by default, the job frees its runtime caches every 500 pairs instead.

Parameters
ParameterDescription
int $pairsDefault 500 in a web request, 0 under WP-CLI.
Returns
int
Example3 lines
add_filter( 'perflocale/jobs/bulk_translate/slice_max_pairs', function (): int {
	return 100; // Smaller slices, lower peak memory.
} );

Source src/Background/Jobs/BulkTranslateJob.php(opens GitHub in a new tab)

Items the SQLite string-group repair handles per slice. Default 500, kept between 50 and 5000.

Each slice is one transaction: a larger slice means fewer transactions and progress writes, a smaller one a shorter write lock on the database and less work redone after an interruption.

Parameters
ParameterDescription
int $sizeDefault 500.
Returns
int
Example1 line
add_filter( 'perflocale/strings/sqlite_repair_batch_size', static fn(): int => 200 );

Source src/Strings/SqliteGroupRepair.php(opens GitHub in a new tab)

Reliability & Circuit Breakers 5

PerfLocale wraps every external dependency (MT provider, webhook receiver, FX sync, geo-IP lookup) in a circuit breaker. After N consecutive failures within a sliding window, the breaker trips OPEN and subsequent calls short-circuit with a typed \PerfLocale\Concurrency\BreakerOpenException - no more piling retries onto a failing dependency, no more visitors waiting for a wedged service. Site Health surfaces every open breaker with a one-click reset; recovery is automatic when the upstream comes back. The hooks below tune trip / window / cooldown thresholds.

Breaker keys follow the pattern mt_<provider_id> (e.g. mt_deepl, mt_wp_ai_client), webhook_<uuid>, fx_sync, and geo_<provider_id> - where <provider_id> is whatever id a site registered through perflocale/geo/providers, since no geo provider ships built in.

Global kill-switch.

When this filter returns true, every breaker becomes a no-op - is_open() always returns false and record_failure() never trips anything. Use to temporarily disable circuit-breaker behaviour while debugging upstream issues, or to ship a hotfix that opts out without rolling back the plugin.

Parameters
ParameterDescription
bool $disabledDefault false.
Returns
bool
Example2 lines
// Disable all breakers (debugging only - re-enable as soon as possible).
add_filter( 'perflocale/breaker/disabled', '__return_true' );

Source src/Concurrency/Breaker.php(opens GitHub in a new tab)

Number of failures within the rolling window that trips a breaker into OPEN.

Global default 5. Per-key variant perflocale/breaker/threshold/<key> overrides for a specific breaker (e.g. perflocale/breaker/threshold/mt_deepl).

Parameters
ParameterDescription
int $thresholdDefault 5. Floor 1.
Returns
int
Example5 lines
// Tighten threshold for the MT provider - trip after 2 failures.
add_filter( 'perflocale/breaker/threshold/mt_deepl', static fn(): int => 2 );

// Loosen the global default - tolerate more transient errors.
add_filter( 'perflocale/breaker/threshold', static fn(): int => 10 );

Source src/Concurrency/Breaker.php(opens GitHub in a new tab)

Length of the sliding window (seconds) within which the failure counter accumulates.

A failure older than this resets the counter. Global default 300s (5 minutes). Per-key variant perflocale/breaker/window_seconds/<key>.

Parameters
ParameterDescription
int $secondsDefault 300. Floor 1.
Returns
int
Example2 lines
// Short window for webhook deliveries - only count failures in the last minute.
add_filter( 'perflocale/breaker/window_seconds/webhook_my-uuid', static fn(): int => 60 );

Source src/Concurrency/Breaker.php(opens GitHub in a new tab)

How long the breaker stays in OPEN state before allowing a single probe call (HALF_OPEN).

If the probe succeeds, the breaker closes; if it fails, it re-opens for another cooldown cycle. Global default 300s. Per-key variant perflocale/breaker/cooldown_seconds/<key>.

Dev-mode guard: a non-int return (on either the global or per-key form) triggers _doing_it_wrong() and the breaker falls back to the default 300s cooldown.

Parameters
ParameterDescription
int $secondsDefault 300. Floor 1.
Returns
int
Example5 lines
// Long cooldown for an unreliable third-party FX API - try once per hour.
add_filter( 'perflocale/breaker/cooldown_seconds/fx_sync', static fn(): int => HOUR_IN_SECONDS );

// Quick recovery for your own internal services.
add_filter( 'perflocale/breaker/cooldown_seconds/webhook_internal-uuid', static fn(): int => 30 );

Source src/Concurrency/Breaker.php(opens GitHub in a new tab)

How long the single HALF_OPEN probe holds its turn.

When a breaker’s cooldown expires, exactly one caller is let through to test the dependency; everyone else keeps getting the refusal until that probe reports back. This is the lease that makes “exactly one” true under concurrency, and its TTL is the backstop for a probe that dies without reporting. Defaults to the breaker’s own cooldown, clamped to 30-300 seconds.

Parameters
ParameterDescription
int $seconds-
string $key-
Returns
int
Example5 lines
// A provider whose timeout can reach several minutes: hold the turn longer
// so a slow probe is not overtaken while it is still waiting.
add_filter( 'perflocale/breaker/probe_lease_seconds', static function ( int $seconds, string $key ): int {
	return 'mt_my_slow_provider' === $key ? 300 : $seconds;
}, 10, 2 );

Source src/Concurrency/Breaker.php(opens GitHub in a new tab)

BreakerOpenException (catchable)

Not a hook but the typed companion to the breaker filters above. When a breaker is OPEN, AbstractProvider::make_request() throws this instead of firing the HTTP call. Catch it to route the request to a graceful-degradation path (a cached translation, a "service unavailable" UI, leaving the source text in place) without conflating with genuine downstream errors.

try {
	$translated = $mt_service->translate_text( $text, 'en', 'de' );
} catch ( \PerfLocale\Concurrency\BreakerOpenException $e ) {
	// Provider is being rate-limited / auth-rejected / 5xx'd.
	// $e->get_breaker_key() returns "mt_deepl" or similar.
	// $e->getMessage() is human-readable (includes the retry-in seconds).
	$translated = my_fallback_cached_translation( $text );
} catch ( \RuntimeException $e ) {
	// Genuine downstream error (not a breaker pre-emption) - log + skip.
}

Class: \PerfLocale\Concurrency\BreakerOpenException extends \RuntimeException
File: src/Concurrency/BreakerOpenException.php

Export & Import 5

Extension points for the data-export / data-import lifecycle. Pair the export/sections filter with the matching import/section/<name> action to round-trip your addon's own data through wp perflocale export / import.

Register one or more top-level sections that your addon wants to add to the exported JSON envelope (siblings of data, settings, roles).

Each entry in the returned array is written as "<section_name>": <json> at the envelope's top level. Reserved core keys (perflocale_export, version, format_version, exported_at, site_url, sections, settings, addon_settings, disabled_addons, roles, data) cannot be overwritten and are silently dropped if a callback tries.

Parameters
ParameterDescription
array $sectionsexisting sections (empty by default). Keyed by section name; each value is JSON-serialisable.
array $context{ requested: string[], format_version: int }. requested is the user-chosen section list (skip your section if it's not requested); format_version is the envelope schema version (currently 1).
Returns
array<string, mixed>.
Example13 lines
// Acme Reviews addon: ship its rows in every export.
add_filter( 'perflocale/export/sections', static function ( array $sections, array $context ): array {
	global $wpdb;
	$rows = $wpdb->get_results(
		"SELECT id, post_id, language_id, body FROM {$wpdb->prefix}perflocale_addon_acme_reviews",
		ARRAY_A
	);
	$sections['acme_reviews'] = [
		'schema_version' => 2,
		'rows'           => $rows ?: [],
	];
	return $sections;
}, 10, 2 );

Source src/Admin/DataExporter.php(opens GitHub in a new tab)

Whether a data import's own writes start Sync Fields mirroring. Default false.

An import (Settings → Export & Import, or wp perflocale import) writes each post the value the file holds for it, in Merge and in Replace: a value it writes on one member of a translation group is not copied to the other members, so the import reproduces the file.

Return true to have the import's writes mirror as a save does: when Sync Fields lists the key (after a Replace, the file's own Sync Fields list), the value is copied to the post's translations. The import then no longer reproduces the file exactly: translations can get values the file does not give them, a Merge can change values this site already had, and translations that the exporting site held back (marked as needing an update) are overwritten. Only _perflocale_seo_exclude is both carried by an export and allowed in Sync Fields today.

Parameters
ParameterDescription
bool $mirrorDefault false.
Returns
bool
Example1 line
add_filter( 'perflocale/import/sync_fields_mirroring', '__return_true' );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

Rows the data importer reads and checks per piece. Default 500, kept between 50 and 5000.

The importer reads an export file a piece at a time: for each piece it runs one look-up query per unique key of the table, so rows that would collide are skipped before any write, and then inserts the rows. Larger pieces mean fewer look-ups; smaller ones keep peak memory lower. wp perflocale import --batch-size (and network-import) sets it for one run.

Parameters
ParameterDescription
int $sizeDefault 500.
string $table_nameTable being imported, for example translation_links.
Returns
int
Example1 line
add_filter( 'perflocale/import/batch_size', static fn( int $size, string $table ): int => 'translation_links' === $table ? 2000 : $size, 10, 2 );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

The settings a Replace import keeps as this site has them.

These settings describe the site rather than its translation data: its per-language domains, its machine-translation endpoints, its uninstall choice and its paused background queue. A Replace import applies the file's other settings and leaves these as they are; the import report names each one whose value in the file was different. Return an empty array to apply every setting from the file. API keys are never in an export file, so they are always kept.

Parameters
ParameterDescription
array $keysSetting keys. Default: language_domains, mt_libre_url, mt_agency_url, delete_data_on_uninstall, background_paused.
Returns
array
Example2 lines
// Moving a site to a new domain set: take the domains from the file too.
add_filter( 'perflocale/import/keep_target_settings', static fn( array $keys ): array => array_values( array_diff( $keys, [ 'language_domains' ] ) ) );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

The size of each piece in which the Export & Import screen sends an import file larger than PHP accepts in one request.

Lower it when a proxy or firewall in front of the site refuses requests smaller than PHP's own limits; the screen also halves a piece the server refuses as too large, down to 256 KB. The result is kept between 256 KB and 8 MB and at most three quarters of the smaller of PHP's upload_max_filesize and post_max_size.

Parameters
ParameterDescription
int $bytesDefault: three quarters of the smaller of PHP's upload limits, between 256 KB and 8 MB.
Returns
int
Example2 lines
// A firewall that refuses request bodies over 1 MB.
add_filter( 'perflocale/import/upload_piece_bytes', static fn(): int => 1024 * 1024 );

Source src/Admin/ImportUpload.php(opens GitHub in a new tab)

Machine Translation (bulk) 2

Short-circuit a single (source post, target language) row inside the bulk-translate job before the default TranslationService::translate_post() call.

Mirrors WordPress's pre_* filter convention: return null (the default) to let the regular flow run; return any other value to skip the default call and treat the returned value as this row's result.

  • Return [ 'post_id' => int ] - counted as created.
  • Return any other array, string, or false - counted as skipped (the after_translate action still fires, with short_circuited => true).
  • Return null - default flow runs.
Parameters
ParameterDescription
mixed $prenull by default.
int $source_idsource post ID.
string $target_slugtarget language slug (e.g. de, ar-EG).
array $context{ source_id, target_slug, target_id, provider, processed, total }. provider is the configured MT provider key (deepl / google / etc.). processed / total let you log progress.
Returns
mixed (see semantics above).
Example14 lines
// Skip MT on posts tagged "do-not-translate".
add_filter( 'perflocale/mt/bulk/before_translate', static function ( $pre, int $source_id, string $target_slug, array $ctx ) {
	if ( has_term( 'do-not-translate', 'post_tag', $source_id ) ) {
		return 'skip'; // counted as skipped, no provider call made
	}
	return $pre;
}, 10, 4 );

// Route Arabic translations through a different provider by claiming a result.
add_filter( 'perflocale/mt/bulk/before_translate', static function ( $pre, int $source_id, string $target_slug, array $ctx ) {
	if ( $target_slug !== 'ar' ) { return $pre; }
	$post_id = my_addon_translate_via_custom_arabic_provider( $source_id );
	return $post_id ? [ 'post_id' => $post_id ] : 'skip';
}, 10, 4 );

Source src/Background/Jobs/BulkTranslateJob.php(opens GitHub in a new tab)

Hard ceiling on how many strings a single Bulk-Translate Strings dispatch (Strings admin page → bulk MT toolbar) will touch.

Default 5000. The cap applies to all three selection modes - an explicit ID list, a filtered set, or all strings - so a misclick on a 100k-row table can’t turn into one runaway provider bill. Raise it only if you genuinely intend to translate more strings in one run, and have the provider quota/budget for it.

This is not the same as the per-job Auto-mode cutoff - that’s perflocale/jobs/threshold/<type>, which decides whether a dispatch runs inline or async based on args_size() (string_count × target_count). This filter instead bounds how many strings the dispatch resolves in the first place. There is no UI/settings field for this ceiling; it is code-only.

Parameters
ParameterDescription
int $capthe ceiling, 5000 by default. The return value is cast to int.
Returns
int - the maximum number of strings a single dispatch may translate.
Example2 lines
// Roomy host with budget: allow up to 20,000 strings per bulk dispatch.
add_filter( 'perflocale/mt/bulk_string_max_per_dispatch', fn() => 20000 );

Source src/Background/Jobs/BulkStringTranslateJob.php(opens GitHub in a new tab)

Plugin Lifecycle 2

Sites fetched per iteration when PerfLocale walks a multisite network - on network activation (perflocale.php), on the network deactivation sweep (src/Deactivator.php) and on the uninstall sweep (src/Database/SiteCleanup.php).

One number tunes all three.

Parameters
ParameterDescription
int $chunkDefault 100. Floored at 1 on activation; the deactivation and uninstall sweeps fall back to 100 for a non-numeric return.
Returns
int
Example1 line
add_filter( 'perflocale/activation/chunk_size', fn() => 25 );

Source perflocale.php(opens GitHub in a new tab) src/Deactivator.php(opens GitHub in a new tab) src/Database/SiteCleanup.php(opens GitHub in a new tab)

What a Polylang import does when PerfLocale already has another language for an imported post or term: keep it (default) or take Polylang's.

keep (default) leaves PerfLocale's language and lists the item. source sets Polylang's language and lists the change, with no record of the old value. Neither value moves another post or term out of its translation group or takes its place: those sets are listed and left as they are under both values, and so is a post or term that Polylang has in the default language.

A post or term alone in its translation group, with a language no import wrote, has no language yet: both values give it Polylang's, the default language included. When that replaces a language other than the default, the change is listed.

Read once per object type when an import starts; any value other than source counts as keep. Put it in a plugin or must-use plugin (an import from the settings screen runs in its own request) and remove it after the move.

Parameters
ParameterDescription
string $policykeep.
string $sourcepolylang.
string $object_typepost or term.
Returns
string
Example1 line
add_filter( 'perflocale/migration/language_conflict_policy', static fn(): string => 'source' );

Source src/Migration/PolylangImporter.php(opens GitHub in a new tab)

Edge Integration 4

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.

Parameters
ParameterDescription
bool $enabledCurrent effective state.
Returns
bool
Example1 line
add_filter( 'perflocale/edge/enabled', '__return_true' );

Source src/Settings.php(opens GitHub in a new tab)

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

Parameters
ParameterDescription
bool $acceptDefault true.
string $slugCandidate language slug from header/cookie.
Returns
bool
Example6 lines
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 );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

CDN Cache-Tag Headers 4

SEO Schema Enrichment 1

Rate Limits & Scan Caps 4

Per-user hourly ceiling on machine-translation requests.

All three MT entry points share this budget: POST /perflocale/v1/machine-translate, POST /perflocale/v1/block-translate, and the perflocale/translate-post Ability (which is itself reachable over REST and MCP). Return 0 to disable the per-user cap only - the site-wide cap from perflocale/mt/rate_limit_site still applies and will still return HTTP 429. Throttling stops entirely only when both filters return 0.

Parameters
ParameterDescription
int $limitDefault 500 requests per hour.
Returns
int
Example1 line
add_filter( 'perflocale/mt/rate_limit', fn() => 1000 );

Source src/Translation/MtRateLimiter.php(opens GitHub in a new tab)

Site-wide hourly ceiling on machine-translation requests, summed across every user and across all three MT entry points.

Pairs with perflocale/mt/rate_limit (per-user). The per-user and site counters share a single global lock so a hostile editor with the MT capability can't fan out parallel requests to drain the site-wide budget faster than the rate-limit check can register them; a request that cannot take that lock is refused rather than admitted, so contention cannot be used to slip past the cap.

Parameters
ParameterDescription
int $limitDefault 5000 requests per hour, summed across all users. Set to 0 to disable the site cap.
Returns
int
Example5 lines
// Tighten the global ceiling for a shared budget across an editorial team.
add_filter( 'perflocale/mt/rate_limit_site', fn() => 2000 );

// Disable the site cap entirely (per-user cap still applies).
add_filter( 'perflocale/mt/rate_limit_site', fn() => 0 );

Source src/Translation/MtRateLimiter.php(opens GitHub in a new tab)

Tune the data-quality gate that every import passes BEFORE any database write.

The gate rejects invalid UTF-8, null bytes, cardinality bombs, oversized values, and malformed rows - errors name the exact JSON path so the operator can fix the file.

Only the core sections and the sections of an add-on that hooks perflocale/import/section/<name> are read; any other top-level section is walked over without being kept and named in the report as not imported. The same limits apply to wp perflocale network-import. A file over a limit is refused before anything is written.

Parameters
ParameterDescription
array $limitsKeys: max_languages (default 500), max_rows_per_table (default: the rows that fit in PHP’s memory_limit: a Merge keeps about 512 bytes per row of the file’s largest table, using up to 80% of the memory free when the import starts; never fewer than 500,000 and never more than the import file ceiling divided by 3; with no memory limit (-1, as under WP-CLI) it is that file ceiling. A file over it is refused before anything is written), max_value_bytes (default 1,048,576), max_section_bytes (default 33,554,432: the largest single row and the largest add-on section the importer reads), max_config_bytes (default 4,194,304: the largest of the file’s own top-level keys, such as settings, roles and addon_settings, which hold settings and lists, never content), max_captured_bytes (default 67,108,864: the most the importer keeps in all while it reads a file’s structure, that is everything outside the table rows), max_top_level_members (default 1,000: the most top-level sections a file may have), max_data_members (default 100,000: the most tables and row lists in the file’s data section; for wp perflocale network-import, the most sites).
Returns
array
Example6 lines
// Agency importing a 900-language terminology corpus.
add_filter( 'perflocale/import/quality_limits', fn() => [
	'max_languages'      => 1000,
	'max_rows_per_table' => 2000000,
	'max_value_bytes'    => 4 * 1048576,
] );
Example5 lines
// A site whose settings export is larger than 4 MB.
add_filter( 'perflocale/import/quality_limits', static function ( array $limits ): array {
	$limits['max_config_bytes'] = 8 * 1048576;
	return $limits;
} );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

Limits on uploads of import files in progress (per user and for the whole site).

The Export & Import screen sends a file larger than PHP accepts in one request in pieces, staged on the server until it is imported. An upload counts from its start until it is imported, cancelled or fails, or until an hour passes without a piece. Bytes are the sizes announced at the start. A start that would pass a limit is refused with a message on the screen. A missing key keeps its default; every value is at least 1.

Parameters
ParameterDescription
array $quotaKeys: user_uploads (default 3), user_bytes (default twice perflocale/import/max_file_bytes: 1 GB), site_uploads (default 10), site_bytes (default four times perflocale/import/max_file_bytes: 2 GB).
Returns
array
Example5 lines
// A site with many administrators who import at the same time.
add_filter( 'perflocale/import/upload_quota', static function ( array $quota ): array {
	$quota['site_uploads'] = 20;
	return $quota;
} );

Source src/Admin/ImportUpload.php(opens GitHub in a new tab)

Environment Gates 1

Outward-facing dispatches fail closed on non-production environments: staging and development clones carry the production webhook URLs in their cloned database, so without the gate a clone silently fires production endpoints.

Managed hosts (WP Engine, Kinsta, etc.) set WP_ENVIRONMENT_TYPE=staging on clones automatically; WordPress defaults to production when unset, so ordinary single-environment sites are unaffected. Return true to opt a surface back in.

Parameters
ParameterDescription
bool $allowDefault false.
string $featureDispatch surface. Currently only 'webhooks'.
string $environmentCurrent wp_get_environment_type() value.
Returns
bool
Example4 lines
// QA environment that owns its own webhook endpoints - allow webhooks there.
add_filter( 'perflocale/dispatch/allow_non_production', function ( bool $allow, string $feature ): bool {
	return $feature === 'webhooks';
}, 10, 2 );

Source src/Helper.php(opens GitHub in a new tab)

Garbage Collection & Retention 2

PerfLocale ships a layered GC system so every plugin-owned data store stays bounded. These filters tune the retention windows; the matching do_action hooks fire after each GC pass (see the Actions section below).

Number of days a perflocale_strings row can go un-rediscovered before the daily mark-and-sweep GC deletes it (and cascades to the matching perflocale_string_translations rows).

Every StringRepository::bulk_insert() call (the scanner) and every register_setting_string() call touches the row's last_seen_at, so strings that are still in plugin / theme code or actively registered never expire. The sweep is armed by the string scan, not by the clock: the GC deletes nothing unless a complete string scan finished within the retention window (recorded in the perflocale_strings_last_full_scan option, and deliberately left un-armed when any scan target was skipped). On a site that never runs a full scan, strings whose source disappears (uninstalled plugin, removed theme, deleted code) are kept indefinitely rather than aged out - fail-safe, so a never-scanned corpus imported from PO or a migration is never deleted.

Parameters
ParameterDescription
int $daysDefault 90. Set to 0 to disable the GC.
Returns
int
Example5 lines
// Hold on to potentially-stale strings for a full year.
add_filter( 'perflocale/strings/stale_retention_days', fn() => 365 );

// Disable the strings GC entirely (rows accumulate until manually deleted).
add_filter( 'perflocale/strings/stale_retention_days', fn() => 0 );

Source src/Database/Repository/StringRepository.php(opens GitHub in a new tab)

Allowlist of context values that the strings GC will never delete, even when last_seen_at exceeds the retention window.

Empty by default. Use it for manually-registered strings whose register_setting_string() code path may not run regularly - settings labels, addon-registered templates, and the like.

Parameters
ParameterDescription
string[] $contextsDefault [].
Returns
array
Example6 lines
// Preserve a custom-addon setting label that's only re-registered when the
// addon's admin page is rendered (might be longer than 90 days between hits).
add_filter( 'perflocale/strings/manual_contexts', function ( array $contexts ): array {
	$contexts[] = 'my_addon_settings_label';
	return $contexts;
} );

Source src/Database/Repository/StringRepository.php(opens GitHub in a new tab)

Addons & integrations 1

Addon settings UI 2

Filter the sanitized settings being written for an addon subtab before they are merged into perflocale_settings.

Parameters
ParameterDescription
array $values-
string $tab-
Returns
array
Example7 lines
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 );

Source src/Admin/Pages/SettingsPage.php(opens GitHub in a new tab)

GeoIP 2

Short-circuit the GeoIP lookup with your own data source (e.g. a Cloudflare CF-IPCountry header).

Parameters
ParameterDescription
string $country(ISO-3166-1 alpha-2, or empty)
string $ip-
Returns
string
Example6 lines
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 );

Source src/Router/GeoRedirect.php(opens GitHub in a new tab)

Routing & detection 2

Post types treated as children of their parent (e.g. WooCommerce variations).

Query filtering follows the parent's language to avoid split listings.

Parameters
ParameterDescription
array $typesDefault [ 'product_variation' ].
Returns
array
Example4 lines
add_filter( 'perflocale/query/child_post_types', function ( array $types ): array {
	$types[] = 'my_variant_cpt';
	return $types;
} );

Source src/Translation/PostQueryFilter.php(opens GitHub in a new tab)

Language switcher (menu placement) 1

Whitelist specific theme menu locations that should receive the language switcher.

Parameters
ParameterDescription
array $locationsthe theme location slugs that should receive the switcher. Defaults to the locations ticked on Settings → Language Switcher (“Append to classic menus”), kept verbatim so mixed-case slugs like headerMenu stay intact; return a wider or narrower set to override, or an empty array to allow every menu.
\stdClass $argsthe current wp_nav_menu args.
Returns
array
Example3 lines
add_filter( 'perflocale/switcher/menu_locations', function ( array $locations, \stdClass $args ): array {
	return [ 'primary', 'header-utility' ];
}, 10, 2 );

Source src/Frontend/LanguageSwitcher.php(opens GitHub in a new tab)

Machine translation 1

Filter the wp_kses-style allowed-tags map applied to translated HTML returned by MT providers.

The default is derived from core rather than hand-maintained: wp_kses_allowed_html( 'post' ) minus object, textarea and button, plus source (which core omits but the core/video and core/audio blocks emit). Because it tracks core, it doesn't silently strip block markup the way a fixed list does. Tighten or loosen from there.

Parameters
ParameterDescription
array $allowed-
Returns
array
Example4 lines
add_filter( 'perflocale/mt/allowed_html', function ( array $allowed ): array {
	$allowed['mark'] = [];
	return $allowed;
} );

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Strings & content 1

Meta-key fragments that are never copied onto a translation nor sent to machine translation - credentials, tokens and other secrets.

Defaults: _password, _secret, _token, _api_key, _apikey, stripe, paypal, _credentials, _encrypted, _auth. Despite the name these are not regular expressions, and the pattern’s own text matches in any case. Wrapping a pattern in regex delimiters makes it match nothing.

  • The defaults match whole parts of the key. A default starts at the start of the key, after a character that is not a letter or digit (a leading _ is that character), or at a capital letter that follows a lower-case one, as in camelCase. It ends at the end of the key or at such a boundary, and before that end any sequence of these may follow it directly: a plural s, digits, the words key, token, secret, hash, salt, code and id, and the auth forms authorize, authorization and authentication. The pattern and these words match in any case, but only a change from lower case to upper case counts as a boundary. So _auth matches api_auth, _auth_token, _authkey, _authTokenHash and _authorize_net_login_id, and stripe matches _stripe_customer_id, stripeSecretKey, wcStripeKey and stripetokenid, but book_author, _author_bio, _authorBio, _AUTHOR, _authority and pinstripe_color are ordinary keys.
  • Any other entry, such as one you add, matches anywhere in the key (a substring match), so _my_encrypted_ also matches _my_encrypted_v2.

The same list applies in three places:

  • the post-meta copy made when a translation is created;
  • content sync of seed-only keys: a matching key is not synced to any language (keys you mirror through Sync fields or perflocale/sync/mirror_meta_keys are your explicit choice and are not checked);
  • machine translation of meta: a matching key is removed from the perflocale/mt/translatable_meta_keys list, so it is never sent to a provider or overwritten.

The filter runs each time one of these operations starts, so its answer can differ per site on multisite. Entries that are not strings are ignored.

Parameters
ParameterDescription
array $patternskey fragments: the defaults matched as whole key parts, other entries as substrings in any case.
string $contextWhich list: copy (meta copied onto a translation) or machine_translation (meta sent to machine translation; that list adds token, secret, api_key, apikey, password, license and licence). Passed to callbacks that accept two arguments.
Returns
array
Example5 lines
add_filter( 'perflocale/translation/dangerous_meta_patterns', function ( array $patterns ): array {
	// Substring, not regex - matched case-insensitively anywhere in the key.
	$patterns[] = '_my_encrypted_';
	return $patterns;
} );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab) src/Translation/ContentSync.php(opens GitHub in a new tab) src/MachineTranslation/MetaTranslator.php(opens GitHub in a new tab)

Admin & permissions 14

Switch PerfLocale's WordPress Abilities API integration off, or force it on.

The Abilities API ships in WordPress 6.9 and exposes plugin operations as discoverable, REST-callable, AI-tool-friendly abilities. PerfLocale registers six abilities in the perflocale-translation category. The four read-only abilities register by default. The default is the abilities_enabled setting (PerfLocale → Settings → Advanced → AI Agent Abilities), so it can be switched off from the admin as well as from code. The two abilities that WRITE - perflocale/create-translation and perflocale/translate-post - stay off until you opt in separately via perflocale/abilities/write_enabled or its checkbox.

Once enabled, the following abilities register on the wp_abilities_api_init action and become accessible via wp_get_abilities(), the /wp/v2/abilities REST endpoint, and any tool that consumes the registry (Claude Desktop, Cursor, MCP-aware integrations):

  • perflocale/list-languages - return all configured languages.
  • perflocale/get-translations - fetch the translation group for a post.
  • perflocale/detect-language - report which language a post or term is assigned to.
  • perflocale/convert-url - convert a URL between languages.
  • perflocale/translate-post - machine-translate a post into a target language.
  • perflocale/create-translation - create a new translation linked to an existing post.

On WordPress 6.8 and earlier the Abilities API isn't available; this filter has no effect because the registration callback never fires.

Parameters
ParameterDescription
bool $enabledDefaults to the abilities_enabled setting, which ships on.
Returns
bool
Example2 lines
// Turn the integration off entirely (it is on by default).
add_filter( 'perflocale/abilities/enabled', '__return_false' );

Source src/Bootstrap.php(opens GitHub in a new tab)

Toggle PerfLocale's block-editor toolbar extension: the Translate dropdown on text blocks (machine-translate, fill in from the source translation, show only for certain languages, and the do-not-translate marker).

Returning false removes the whole dropdown.

Parameters
ParameterDescription
bool $enabledDefault true.
Returns
bool
Example1 line
add_filter( 'perflocale/block_toolbar/enabled', '__return_false' );

Source src/Bootstrap.php(opens GitHub in a new tab)

Upper bound on the number of published posts/pages loaded when PerfLocale draws the per-language badges on the Appearance → Menus screen (the language codes appended to items in the “Add to Menu” metabox).

Rows are ordered by post_modified DESC, so the most recently edited content keeps its badge on very large sites.

Parameters
ParameterDescription
int $limitDefault 5000. 0 means unlimited (the LIMIT clause is dropped entirely - use carefully).
Returns
int
Example1 line
add_filter( 'perflocale/menu/badge_post_limit', fn() => 10000 );

Source src/Translation/MenuManager.php(opens GitHub in a new tab)

Override the set_time_limit() value used by the two bulk admin AJAX handlers that raise it: Create taxonomy translations (perflocale_create_taxonomy_translations) and Assign post languages (perflocale_assign_post_languages).

The Create WooCommerce page translations handler does not change the time limit, so this filter has no effect there. Both handlers are gated by check_ajax_referer() + current_user_can('manage_options'), and the time-limit raise happens only AFTER those gates pass. Default 0 (no limit). Set a positive int (seconds) if your host enforces a hard cap and you'd rather error out cleanly than have the handler abort mid-batch.

Parameters
ParameterDescription
int $secondsDefault 0 (no limit).
Returns
int
Example2 lines
// Cap the bulk admin handlers at 10 minutes on this host.
add_filter( 'perflocale/admin/bulk_time_limit', fn(): int => 600 );

Source src/Bootstrap.php(opens GitHub in a new tab)

Fraction of PHP’s memory_limit at which the bulk admin handlers stop the current run and hand back a resume cursor instead of continuing.

Default 0.8; clamped to between 0.5 and 0.95, so a filter cannot disable the guard. Lower it on a host that reports a generous limit it cannot actually honour.

Parameters
ParameterDescription
float $ceilingDefault 0.8.
Returns
float
Example1 line
add_filter( 'perflocale/admin/bulk_memory_ceiling', fn(): float => 0.65 );

Source src/Bootstrap.php(opens GitHub in a new tab)

Upper bound on the rows-per-page value any PerfLocale list screen will honour.

This is a resource bound, not a cosmetic one. Every row on the Translations screen contributes an ids[] checkbox to the bulk-action POST, and PHP silently drops form fields past max_input_vars - so a page size larger than the server can receive back makes a bulk action run on an arbitrary prefix of the operator’s selection and report success for it. Raise it only for a screen that does not post one field per row, and only after checking the host’s max_input_vars. The option name is passed so different screens can carry different ceilings.

Parameters
ParameterDescription
int $ceilingComputed ceiling: max( 10, floor( max_input_vars * 0.4 ) ), or 400 when max_input_vars is unset. The return is floored at 1.
string $optionThe per-page option being read (e.g. perflocale_strings_per_page).
Returns
int
Example3 lines
add_filter( 'perflocale/admin/per_page_ceiling', function ( int $ceiling, string $option ): int {
	return 'perflocale_strings_per_page' === $option ? 500 : $ceiling;
}, 10, 2 );

Source src/Helper.php(opens GitHub in a new tab)

Wall-clock budget for one run of Assign Default Language.

When it is spent the handler returns a cursor and the browser continues from there, so a large site finishes across several requests rather than hitting a timeout. Default 15.0.

Parameters
ParameterDescription
float $secondsDefault 15.0.
Returns
float
Example1 line
add_filter( 'perflocale/assign_languages/budget_seconds', fn(): float => 5.0 );

Source src/Bootstrap.php(opens GitHub in a new tab)

Whether the WordPress Abilities API registration includes the WRITE abilities (creating and updating translations) as well as the read-only ones.

Mirrors the Advanced setting of the same name, so a site can keep the setting off and still enable writes for one environment from code, or force it off regardless of the setting.

Parameters
ParameterDescription
bool $enabledDefaults to the abilities_write_enabled setting.
Returns
bool
Example1 line
add_filter( 'perflocale/abilities/write_enabled', '__return_false' );

Source src/AbilitiesRegistrar.php(opens GitHub in a new tab)

Webhooks 2

Capability required to register a webhook via POST /perflocale/v1/webhooks.

The default is manage_network_options on multisite, so only network administrators can register one there, and manage_options on single site. Listing and deleting webhooks stay at manage_options. A filter that returns an empty string falls back to the default rather than opening the route.

Parameters
ParameterDescription
string $capability-
WP_REST_Request $request-
Returns
string
Example5 lines
// On a network that deliberately delegates webhook creation
// to subsite administrators.
add_filter( 'perflocale/webhooks/register_capability', function ( string $capability, $request ): string {
	return 'manage_options';
}, 10, 2 );

Source src/Api/WebhookController.php(opens GitHub in a new tab)

Verdict on whether a webhook URL may be used - return false to reject.

It runs in two places: when a webhook is registered (POST /perflocale/v1/webhooks, which then answers 400 unsafe_url naming the reason) and again before every delivery attempt. The built-in address checks block private, shared, reserved, multicast, loopback and metadata addresses; use this filter to layer org-specific policy on top.

It is not the first gate. Four syntactic rules run before the filter and return early, so a filter cannot re-allow them: an unparseable URL, a scheme other than http / https, credentials embedded in the URL (user:pass@), and a port outside core's http_allowed_safe_ports (80, 443, 8080 - PerfLocale reads core's own filter, so widening it for WP_Http widens it here too). WordPress refuses those URLs at request time anyway, so they are refused at registration rather than stored as a webhook that could never deliver.

Parameters
ParameterDescription
bool $safe-
string $url-
Returns
bool
Example4 lines
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 );

Source src/Api/WebhookController.php(opens GitHub in a new tab)

Actions

Caching 2

Fires when a post’s public readability changes - published to private, private to published, trashed, restored - carrying every front-end URL affected by that change: the post’s own permalink and the permalink of every translation in its group.

This exists because a translated page is a separate post with its own language-prefixed URL, and a full-page cache generally does not know those URLs exist. Worse, a cache that purges by permalink computes it during an admin or CLI request, where PerfLocale deliberately does not add the language prefix - so the URL it purges is not the URL it stored, and a page that has just been made private can stay publicly readable in the cache until it expires.

PerfLocale resolves the real URLs and publishes them. It deliberately calls no cache plugin itself: several per-post purge APIs go on to purge a CDN or Varnish over the network, and a translation plugin should not silently add outbound HTTP to somebody else’s save. With no listener this hook costs nothing.

Purge only the URLs you are given. A full cache flush on every visibility change is exactly the behaviour this hook exists to avoid.

Parameters
ParameterDescription
array $urlsAbsolute front-end URLs, language prefixes included.
int $post_idThe post whose visibility changed.
Example5 lines
add_action( 'perflocale/cache/purge_urls', function ( array $urls, int $post_id ): void {
	foreach ( $urls as $url ) {
		my_page_cache_purge( $url );
	}
}, 10, 2 );

Source src/Cache/CacheInvalidator.php(opens GitHub in a new tab)

Fires when a long-running process (a background job or WP-CLI command) finds data another process changed and drops its in-memory copies.

A long-running process checks at its checkpoints whether another request changed PerfLocale's data. When it did, PerfLocale drops the copies this process holds in memory and fires this action, so an extension can drop its own copies too.

Parameters
None
Example3 lines
add_action( 'perflocale/cache/refreshed', static function (): void {
	My_Addon_Cache::reset();
} );

Source src/Cache/CacheManager.php(opens GitHub in a new tab)

Plugin Lifecycle 6

Fires when the plugin file loads, before plugins_loaded.

The $plugin argument is the bootstrapped container; the perflocale() helper is also available.

Parameters
ParameterDescription
Plugin $pluginThe plugin container instance.
Example4 lines
add_action( 'perflocale/loaded', function ( \PerfLocale\Plugin $plugin ): void {
	// Every PerfLocale service is registered here.
	$router = $plugin->get( 'router' );
} );

Source src/Bootstrap.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string $old_versionPrevious plugin version.
string $new_versionCurrent plugin version.
Example5 lines
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 );

Source src/Database/Migrator.php(opens GitHub in a new tab)

Language Events 9

Fires after the current language is detected from the request.

Parameters
ParameterDescription
string $slugDetected language slug.
string $methodDetection method used.
Example6 lines
add_action( 'perflocale/language/detected', function ( string $slug, string $method ): void {
	// $method is 'url', 'cookie', 'browser', 'edge_hint', or 'default'.
	if ( $method === 'browser' ) {
		// First-time visitor, language detected from browser.
	}
}, 10, 2 );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Fires on multisite when restore_current_blog() puts back a current language other than the one in effect on the blog being left.

Code that resolved something against the current language (a translated menu, template or option value) re-reads it here. It does not fire when the language stays the same, or on sites without blog switches.

Parameters
None
Example3 lines
add_action( 'perflocale/language/restored', static function (): void {
	my_plugin_forget_language_cache();
} );

Source src/Router/LanguageRouter.php(opens GitHub in a new tab)

Fires after the Languages-list reorder UI persists a new sort_order for one or more rows - via drag-and-drop, the drag handle’s arrow keys, or (below 782px, where the handle is hidden) the up/down buttons.

All three route through the same languages/reorder REST endpoint.

Parameters
ParameterDescription
array $ordered_idsLanguage IDs in their new visual order.
int $offsetStarting 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.

Source src/Database/Repository/LanguageRepository.php(opens GitHub in a new tab)

Translation Events 6

Fires after a translation post or term is created.

Parameters
ParameterDescription
int $new_idNew post/term ID.
string $typeObject type ('post' or 'term').
string $target_slugTarget language slug.
int $source_idSource post/term ID the translation was created from. Useful for correlating analytics, CRM entries, or webhook payloads without a secondary translation-group lookup.
Example6 lines
add_action( 'perflocale/translation/created', function ( int $new_id, string $type, string $target_slug, int $source_id ): void {
	if ( $type === 'post' ) {
		// Correlate source → translation, e.g. send to CRM / analytics.
		my_crm_record_translation_pair( $source_id, $new_id, $target_slug );
	}
}, 10, 4 );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab) src/Translation/TermTranslationManager.php(opens GitHub in a new tab)

Fires when one or more of the post-meta / featured-image / taxonomy-term copy steps throws while creating a translation.

The translation post and its group link are still created - a linked translation with missing meta is recoverable via re-sync, whereas rolling back would lose the translator’s work - so this is a notification signal, not an error you need to recover from. The same failures are also written to the _perflocale_meta_copy_errors post meta (surfaced as a per-post admin notice); this hook lets integrators forward the signal to Slack, email, or an error tracker.

Parameters
ParameterDescription
int $new_post_idthe newly-created translation post ID.
int $source_idthe source post the translation was created from.
string $target_slugtarget language slug.
array $errorsone entry per failed step: { step: 'post_meta'|'featured_image'|'taxonomy_terms', message: string }.
Example9 lines
add_action( 'perflocale/translation/meta_copy_failed', function ( int $new_post_id, int $source_id, string $target_slug, array $errors ): void {
	foreach ( $errors as $err ) {
		// $err = [ 'step' => 'post_meta'|'featured_image'|'taxonomy_terms', 'message' => string ]
		error_log( sprintf(
			'PerfLocale copy step "%s" failed for translation %d (source %d, %s): %s',
			$err['step'], $new_post_id, $source_id, $target_slug, $err['message']
		) );
	}
}, 10, 4 );

Source src/Translation/PostTranslationManager.php(opens GitHub in a new tab)

Fires when a translated slug could not be written.

This is not fatal - the object keeps its untranslated slug and its URL still resolves - but without this hook the failure is invisible: set_slug() returns false and most callers have nothing useful to do with it. Use it to tally failures during a bulk import, or to alert when a table is broken. A matching line is also written to the error log, but only when WP_DEBUG is on, so that a broken table during a 10,000-term import cannot flood a production log.

Parameters
ParameterDescription
string $reasonone of:
  • 'empty_slug' - the slug sanitised down to nothing (for example a title made entirely of punctuation or of characters sanitize_title() strips).
  • 'duplicate_exhausted' - the slug and all 99 -2…-100 suffixes are already taken in that language. Realistically only reachable on a pathological data set.
  • 'db_error' - the write itself failed (missing table, permissions, disk).
array $contextobject_type, object_subtype, object_id, language_id, slug (the slug that was attempted) and, for db_error, db_error with the database message.
Example8 lines
add_action( 'perflocale/slug/write_failed', function ( string $reason, array $context ): void {
	if ( $reason === 'db_error' ) {
		my_alerting_channel( sprintf(
			'PerfLocale could not write a slug for %s #%d: %s',
			$context['object_type'], $context['object_id'], $context['db_error']
		) );
	}
}, 10, 2 );

Source src/Database/Repository/SlugTranslationRepository.php(opens GitHub in a new tab)

Settings 1

Translation Status 1

Fires after the Translations-page bulk “Mark as Needs Update” action flips translation_links.status to needs_update for one or more (source_post, target_lang) pairs.

Use this to dispatch translator notifications or push staleness flags to an external system.

Parameters
ParameterDescription
array $source_idsSource post IDs.
array $target_lang_idsTarget language IDs (single ID or all active languages).
int $countNumber of sibling translation_links rows the action matched in the target languages, not the number MySQL reported as changed. A row already sitting at needs_update is matched but not changed, and reporting the changed count would tell the operator “0 translations flagged” over a selection that did match. 0 also means the write failed - the caches are deliberately left alone in that case, because the stored statuses are still the cached ones.

Source src/Admin/AdminController.php(opens GitHub in a new tab)

Machine Translation 7

Fires after machine translation completes successfully.

Parameters
ParameterDescription
int $post_idSource post ID.
string $provider_idProvider identifier.
array $resultTranslation result (title, content, excerpt, post_id; plus meta counts when meta translation ran).
string $target_langTarget language slug.
string $source_langSource language slug.
WP_Post $postSource post object.

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Fires when one or more meta values could not be machine-translated.

The post translation itself is unaffected - failed keys keep their source value and the reason is stored in the _perflocale_meta_mt_errors post meta on the translation.

The WooCommerce addon fires it too when a product’s variation descriptions or custom attribute options could not be translated, with [ '_variation_description', '_product_attributes' ] as $keys.

Parameters
ParameterDescription
int $source_idSource post ID.
int $translation_idTranslation post ID.
string[] $keysMeta keys involved.
string $messageHuman-readable summary.

Source src/MachineTranslation/MetaTranslator.php(opens GitHub in a new tab) addons/woocommerce/PerfLocaleWooCommerce.php(opens GitHub in a new tab)

Fires after machine translation wrote meta values to a translation, once per run, and only when at least one value changed.

Keys whose stored value already was the translation are not listed. It fires before perflocale/mt/meta_translate_failed reports any failure from the same run. Use it to drop anything your code built from the old values, such as a cached render. PerfLocale sends the translation’s page-cache purge signal from it, and the ACF addon clears ACF’s stored values for that post.

Parameters
ParameterDescription
int $translation_idTranslation post ID (the post written to).
string[] $written_keysMeta keys whose value was written.
int $source_idSource post ID.
Example5 lines
add_action( 'perflocale/mt/meta_translated', function ( int $translation_id, array $written_keys, int $source_id ): void {
	if ( in_array( '_my_product_tagline', $written_keys, true ) ) {
		delete_transient( 'my_tagline_render_' . $translation_id );
	}
}, 10, 3 );

Source src/MachineTranslation/MetaTranslator.php(opens GitHub in a new tab)

Fires when machine translation fails with an exception.

It also fires in one more case after the provider has answered: PerfLocale went to create the translation and found that one already existed in the target language, for example because another request created it after PerfLocale last checked. That translation is left as it is rather than overwritten with the machine translation, and the exception message says so.

Parameters
ParameterDescription
int $post_idPost ID.
string $provider_idProvider identifier.
Exception $exceptionThe exception.

Source src/MachineTranslation/TranslationService.php(opens GitHub in a new tab)

Fires after each theme/plugin string is machine-translated and its row saved.

Lets third-party code mark the row for review, push it into an external review queue, or log MT activity. Fires once per string per target language during a bulk string-translation job.

Parameters
ParameterDescription
int $string_idthe source string ID.
int $target_lang_idthe target language ID.
string $translationthe machine-translated text just saved.
string $sourcethe original source text.
Example4 lines
add_action( 'perflocale/mt/string_translated', function ( int $string_id, int $target_lang_id, string $translation, string $source ): void {
	// Queue the freshly machine-translated string for human review.
	my_review_queue_add( $string_id, $target_lang_id );
}, 10, 4 );

Source src/Background/Jobs/BulkStringTranslateJob.php(opens GitHub in a new tab)

Fires after full-mirror meta keys have been written to a sibling translation, and only when at least one was actually written.

Its purpose is generated artefacts a raw meta write cannot reach on its own: an add-on can drop the sibling’s compiled CSS or cached markup so it is rebuilt from whatever was just written, instead of continuing to serve something built from the previous value. PerfLocale’s bundled Elementor, Bricks, Beaver Builder, Oxygen and Oxygen 6 add-ons all listen. Those add-ons do not mirror their layout documents (see perflocale/sync/mirror_meta_keys), so their handlers fire only for the text-free keys that do mirror - and Elementor’s, which gates on _elementor_data, does not fire at all unless someone registers that key as a mirror key deliberately. They are a safety net for exactly that case.

Parameters
ParameterDescription
int $source_idSource post ID.
int $target_idSibling (target) post ID.
array<int, string> $mirror_keysMirror meta keys just written to the sibling.
Example4 lines
add_action( 'perflocale/sync/after_mirror', function ( int $source_id, int $target_id, array $mirror_keys ): void {
	// Drop the sibling's generated CSS so it rebuilds from what was just written.
	delete_post_meta( $target_id, '_elementor_css' );
}, 10, 3 );

Source src/Translation/ContentSync.php(opens GitHub in a new tab)

Cache 1

Background Jobs 4

Lifecycle actions for the background-processing system. See the Background Jobs doc for the full feature reference.

Fires after a job has been successfully enqueued for async execution.

Parameters
ParameterDescription
string $job_idUUID v4 of the new job.
string $typejob type slug (e.g. string_scan, data_import).
string $enginerunner engine: action_scheduler or wp_cron.
array $argsthe dispatch args.
Example3 lines
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 );

Source src/Background/Dispatcher.php(opens GitHub in a new tab)

Fires after a worker finishes a job successfully.

Parameters
ParameterDescription
string $job_id-
string $type-
array $result(the worker's untruncated return value; the copy stored on the job row is replaced with a small truncation stub when it serialises over MAX_RESULT_BYTES = 64 KB).
Example3 lines
add_action( 'perflocale/jobs/completed', static function ( string $job_id, string $type, array $result ): void {
	// Push to your metrics pipeline...
}, 10, 3 );

Source src/Background/WorkerRunner.php(opens GitHub in a new tab)

Fires when a background job fails in its worker: the worker threw an exception and the retry-with-backoff is about to be scheduled (or skipped because the attempt cap has been hit); the job reported a run that did nothing but fail, as a bulk string translation or an import can, and $e is a RuntimeException carrying its reason; or the progress of a job that runs in slices could not be stored.

Receives the FULL untruncated throwable, useful for monitoring - the version persisted to the job row is path-redacted and truncated.

It does not fire when PHP itself stops inside the worker (a fatal error such as running out of memory, or an exit), nor when the stuck-job watchdog or Action Scheduler marks failed a job whose worker is gone. Those jobs show failed with their reason on the Jobs page and in GET /jobs/{id}.

Parameters
ParameterDescription
string $job_id-
string $type-
\Throwable $e-
Example5 lines
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 );

Source src/Background/WorkerRunner.php(opens GitHub in a new tab)

Fires when a long-running worker cooperatively aborts itself in response to an operator cancel mid-flight.

It also fires when the cancel is found only after the worker’s last progress tick, or between two slices of a job that runs in slices. Distinct from perflocale/jobs/failed because it isn't an error - useful for distinguishing operator-canceled from worker-errored in dashboards.

Parameters
ParameterDescription
string $job_id-
string $type-

Source src/Background/WorkerRunner.php(opens GitHub in a new tab)

Export & Import 5

Lifecycle hooks for the data-export / data-import flow. Pair with the Export & Import filters to round-trip addon-shipped data through wp perflocale export/import.

Fires inside DataExportJob::execute() after the export file has been successfully written to disk, BEFORE the job result is stored on the JobState row.

The file is still readable at $path when this action fires - it will be served (and deleted) later by the download endpoint when the operator clicks Download on PerfLocale → Jobs.

Most common use: offsite-backup hooks (copy the export to S3 / Dropbox / a remote shell) and audit-log entries. Note this action fires only from the data_export background job. An export that stays below the async threshold is streamed straight to the browser by DataExporter::download() and never touches disk, so it does not fire this hook - and neither does wp perflocale export, which calls DataExporter::write_to_file() directly.

Parameters
ParameterDescription
string $pathabsolute path to the written export file (always inside uploads/perflocale/exports/).
int $bytessize of the written file.
array<int,string> $sectionsthe section keys that were exported (settings, roles, languages, translations, strings, slugs, …).
Example8 lines
// Mirror every successful site export to S3.
add_action( 'perflocale/export/written', static function ( string $path, int $bytes, array $sections ): void {
	if ( $bytes === 0 ) { return; }
	my_addon_s3_upload(
		$path,
		's3://acme-backups/perflocale/' . gmdate( 'Y/m/d/' ) . basename( $path )
	);
}, 10, 3 );

Source src/Background/Jobs/DataExportJob.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
string $job_id-
string $real_path-
array $job(the JobState row).
Example8 lines
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 );

Source src/Admin/AdminController.php(opens GitHub in a new tab)

Fires AFTER the export file has been streamed to the browser and BEFORE the single-use deletion takes effect.

The file is still on disk at $real_path when this action fires - this is the last chance to act on it. Useful for chaining follow-up work (queue an integrity-check job that re-reads the file, kick off offsite-backup, push a webhook). Don't echo - the HTTP body is already being sent.

Parameters
ParameterDescription
string $job_id-
string $real_path-
array $job-
int $size(bytes served, 0 when filesize() failed).
Example8 lines
// Queue an integrity-check job on the freshly-served export.
add_action( 'perflocale/export/download/after_serve', static function ( string $job_id, string $path, array $job, int $size ): void {
	\PerfLocale\Background\BackgroundEvents::enqueue(
		'my_addon_verify_export_hash',
		[ $path, hash_file( 'sha256', $path ) ],
		0
	);
}, 10, 4 );

Source src/Admin/AdminController.php(opens GitHub in a new tab)

Fires once for every top-level key in the import envelope that isn't a core section (settings, roles, data, etc.).

The action name is dynamic - <name> is the section key your addon used in the matching export filter. Use the same key on both sides so round-tripping works automatically.

$section_data arrives exactly as the file holds it: PerfLocale does not check, clean or re-map your section. Validate and sanitize it in your listener as you would any input your add-on saves, and treat post, term or user IDs in it as meaningful only when the file comes from this site or a copy of it (see what travels between sites). An import PerfLocale refuses stops before any section is dispatched.

Parameters
ParameterDescription
mixed $section_datathe decoded JSON payload your addon wrote to this section. Same shape the export filter returned.
array $context{ replace: bool, format_version: int, file_path: string }. replace mirrors the import's replace-mode flag; truncate your tables when it's true. file_path is the path of the source envelope.
Example13 lines
// Acme Reviews addon: restore its own rows during an import.
add_action( 'perflocale/import/section/acme_reviews', static function ( $section_data, array $ctx ): void {
	global $wpdb;
	$rows = is_array( $section_data ) ? ( $section_data['rows'] ?? [] ) : [];

	if ( $ctx['replace'] ) {
		$wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}perflocale_addon_acme_reviews" );
	}

	foreach ( $rows as $r ) {
		$wpdb->insert( "{$wpdb->prefix}perflocale_addon_acme_reviews", $r );
	}
}, 10, 2 );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

Fires after DataImporter::import() has finished restoring rows and flushing caches, with the final result stats.

Fires for BOTH sync and async (data_import job) code paths. Use for cache invalidation, audit logging, or Slack notifications.

It does not fire for an import that stops before writing anything: an unreadable or invalid file, a file that fails the data-quality checks, or a file from another site that PerfLocale refuses because the copy-confirmation box was not ticked (--force in WP-CLI). See what travels between sites. Nor does it fire when a Replace import fails and is rolled back.

When the person running the import does not have the unfiltered_html capability, PerfLocale cleans string translations, languages and translated slugs with the rules it applies when it saves the same data itself (see imports by accounts without unfiltered_html). $result carries sanitized: the number of stored rows that cleaning changed, whether markup was removed or a value was only reformatted; a row counts once, however many of its values changed. It is 0 when the importer has unfiltered_html, and always 0 for wp perflocale import and network-import, which store the text of those three sections exactly as the file has it. The admin import notice (when the number is above 0), or the job’s details for a queued import, reports the same number.

A Merge import never applies the file’s configuration. When the file carries any, $result also has not_applied, the parts Merge left unapplied (settings, addon_settings, disabled_addons, roles, each also counted in skipped), and notice, the sentence the admin import notice and the job’s details show for it. A Replace import applies them and carries neither key.

Parameters
ParameterDescription
array $result({ imported: int, skipped: int, errors: string[], sanitized: int, not_applied?: string[], notice?: string })
string $file_path-
bool $replace-
Example14 lines
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 cleaned), %d skipped (replace=%s).',
				$result['imported'],
				$result['sanitized'] ?? 0,
				$result['skipped'],
				$replace ? 'yes' : 'no'
			),
		] ),
	] );
}, 10, 3 );

Source src/Admin/DataImporter.php(opens GitHub in a new tab)

Machine Translation (bulk) 1

Fires after every (source post, target language) row inside the bulk-translate job, whether the translation succeeded, failed, or was short-circuited by before_translate.

Use for per-row observability: metrics, monitoring pipelines, audit events.

Rows the job passes over before the translation step reach neither this action nor before_translate: the user lacks perflocale_use_mt or cannot edit the source post, the target language is unknown, a translation already exists, or the user cannot both edit and read the default-language original the new translation would be copied from. Those rows are still counted in the job’s totals (an unknown language as failed, the others as skipped).

Parameters
ParameterDescription
int $source_idsource post ID.
string $target_slugtarget language slug.
array $resultTranslationService result (['post_id' => int] on success; empty array on failure).
array $contextsame 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.
Example9 lines
// 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 );

Source src/Background/Jobs/BulkTranslateJob.php(opens GitHub in a new tab)

Addons 6

Manually-triggered action: do_action( 'perflocale/addons/cache/flush' ) forces PerfLocale to invalidate its bootable-addons transient (the memoised list of which addons pass their compatibility check).

PerfLocale already flushes this automatically on plugin activation and deactivation, theme switch, and plugin/theme upgrade; call this action from a misbehaving environment where one of those lifecycle events didn’t fire.

Parameters
None
Example2 lines
// Force a rebuild of the bootable-addons cache.
do_action( 'perflocale/addons/cache/flush' );

Source src/Addon/AddonRegistry.php(opens GitHub in a new tab)

Fires once, immediately after the registry writes an addon's declared default settings on its first successful boot (the auto-seed pass - see Developer Toolkit → Settings).

Useful for one-shot first-activation work: welcome email, sample-data import, telemetry opt-in prompt. Will NOT fire on subsequent boots even if the user clears all settings - re-firing requires an explicit AddonSettings::forget($id).

Parameters
ParameterDescription
string $addon_idAddon's get_id() value.
array $defaultsThe defaults that were seeded.
Example7 lines
add_action( 'perflocale/addon/seeded', function ( string $addon_id, array $defaults ): void {
	if ( $addon_id !== 'my-addon' ) {
		return;
	}
	// Schedule the welcome email once.
	wp_schedule_single_event( time() + 60, 'myaddon/welcome_email' );
}, 10, 2 );

Source src/Addon/AddonRegistry.php(opens GitHub in a new tab)

Fires inside the storage lock, immediately BEFORE the perflocale_addon_settings option commits a new entry for an addon.

Use for pre-save auditing, validation logging, or cache invalidation that needs to happen while both old and new values are still visible.

Reentrancy: the write lock is non-reentrant. Calling AddonSettings::set() / set_addon() / forget() from a listener will time out at 10 seconds and return false. For cross-addon write reactions, hook perflocale/addon/settings/after_save from a wp_schedule_single_event deferred callback so your write happens outside the lock window.

Parameters
ParameterDescription
string $addon_idAddon being saved.
array $new_entryThe values about to commit.
array $old_entryThe pre-save values (empty array if no entry existed).
Example7 lines
add_action( 'perflocale/addon/settings/before_save', function ( string $addon_id, array $new, array $old ): void {
	error_log( sprintf(
		'addon=%s changed=%s',
		$addon_id,
		wp_json_encode( array_diff_assoc( $new, $old ) )
	) );
}, 10, 3 );

Source src/Addon/AddonSettings.php(opens GitHub in a new tab)

Fires inside the storage lock, immediately AFTER the option commits.

Same reentrancy caveat as before_save. This is the right hook for "addon A reacts to addon B's settings change" via a deferred dispatch:

Neither hook fires on rejected writes (invalid addon id, over the 16 KiB per-addon size cap, or lock acquisition failure). If you need to observe rejections, wrap the call site and check the bool return.

Parameters
identical to before_save.
Example7 lines
add_action( 'perflocale/addon/settings/after_save', function ( string $addon_id, array $new, array $old ): void {
	if ( $addon_id !== 'woocommerce' ) {
		return;
	}
	// Schedule a cache rebuild OUTSIDE the lock window.
	wp_schedule_single_event( time() + 5, 'myaddon/rebuild_currency_cache' );
}, 10, 3 );

Source src/Addon/AddonSettings.php(opens GitHub in a new tab)

WooCommerce 2

Fires after inventory fields are synced across language variants.

Parameters
ParameterDescription
int $product_idSource product ID. For the variation-sync paths this is the source variation ID, not the parent product.
array $synced_idsIDs of sibling translations that were updated.
array $fieldsMeta keys that were synced. For a save from code, only the keys that save changed.

Source src/WooCommerce/InventorySync.php(opens GitHub in a new tab)

GeoIP Redirect 1

Garbage Collection & Retention 2

Fire-and-observe actions emitted at the end of each GC pass. Use them for monitoring (Datadog, New Relic), audit trails (“N strings GC’d on YYYY-MM-DD”), or chained cleanups. None of the callbacks block or alter the GC behaviour. See the matching tuning filters under Garbage Collection & Retention in the Filters section.

Fires after the daily mark-and-sweep GC deletes stale rows from perflocale_strings (and the cascade from perflocale_string_translations).

Only fires when at least one row was deleted - quiet ticks don’t emit. Use it for observability when shipping a wp.org plugin update, so you can confirm the GC actually fired after deploy.

Parameters
ParameterDescription
int $strings_deletedrows deleted from perflocale_strings.
int $translations_deletedrows deleted from perflocale_string_translations via the cascade.
Example6 lines
add_action( 'perflocale/strings/gc_complete', static function ( int $strings_deleted, int $translations_deleted ): void {
	if ( function_exists( 'datadog_metric' ) ) {
		datadog_metric( 'perflocale.gc.strings.deleted', $strings_deleted );
		datadog_metric( 'perflocale.gc.string_translations.deleted', $translations_deleted );
	}
}, 10, 2 );

Source src/Database/Repository/StringRepository.php(opens GitHub in a new tab)

Fires after the defensive orphan-sweep in the daily GC deletes perflocale_string_translations rows whose parent strings.id no longer exists.

Only fires when at least one orphan was found - a healthy install should never trigger this hook, so any callback firing is a signal that some code path bypassed the normal cascade.

Parameters
ParameterDescription
int $deletednumber of orphan rows deleted.
Example5 lines
// Alert when the orphan-sweep actually finds anything - suggests a buggy
// cascade somewhere we should investigate.
add_action( 'perflocale/string_translations/orphans_swept', static function ( int $deleted ): void {
	error_log( '[PerfLocale] string_translations orphan-sweep deleted ' . $deleted . ' rows - investigate.' );
} );

Source src/Database/Repository/StringTranslationRepository.php(opens GitHub in a new tab)

CDN Cache-Tag Headers 4

Fires after PerfLocale flushes its entire cache layer (L1 static memo, every L2 object-cache group, L3 transients).

Callers: the admin “Clear cache” button, wp perflocale cache flush and wp perflocale health-check --fix, a plugin-version database migration, plugin deactivation, every importer run (data import and the WPML / Polylang / TranslatePress migrations), and the bulk “create taxonomy translations” admin action.

It does not fire when a language is added, renamed, updated or reordered - those paths run only the narrower flush_languages(), which emits no action at all. Deleting a language additionally runs flush_slugs() and flush_translations(), which emit perflocale/cache/flush_slugs and perflocale/cache/flush_translations respectively. If you purge a CDN on language changes, hook those as well as this one.

Hook this to drop your CDN’s entire zone or a wildcard tag. PerfLocale itself does not issue remote purges - that’s left to integrators (Cloudflare, Bunny, Fastly).

Parameters
None
Example3 lines
add_action( 'perflocale/cache/flush_all', function (): void {
	my_cdn_purge_zone();
} );

Source src/Cache/CacheManager.php(opens GitHub in a new tab)

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.

Parameters
ParameterDescription
int $object_id-
string $object_type(post or term).
Example4 lines
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 );

Source src/Cache/CacheManager.php(opens GitHub in a new tab)

Fires after PerfLocale flushes the translated-slug cache layer - currently invoked on language deletion (the slug_translations rows for the removed language are deleted alongside the static L1 memo, the persistent L2 group, the L3 transient envelopes, and the perflocale_has_any_slugs zero-state flag - the autoloaded option and its copy in the persistent object cache).

Hook this when your CDN or edge cache holds per-slug routing data that needs to be invalidated on the same event - e.g. a custom URL-shortener or a slug-keyed redirect table at the edge. PerfLocale does not issue remote purges itself; that’s left to integrators.

Parameters
None
Example3 lines
add_action( 'perflocale/cache/flush_slugs', function (): void {
	my_cdn_purge_tags( [ 'perflocale:slugs' ] );
} );

Source src/Cache/CacheManager.php(opens GitHub in a new tab)

Fires when the archive hreflang cache is invalidated, after PerfLocale’s own wipe - the seam for object-cache-backed installs that need to issue an additional purge.

Fires from two paths: a post whose status / language assignment changed, and a term edit (which also flushes its translation siblings).

Parameters
ParameterDescription
int $object_idthe post ID or term ID whose save / edit / delete triggered the flush. The hook does not distinguish the two, so key any purge tag on something you can disambiguate yourself.
Example3 lines
add_action( 'perflocale/cache/flush_archive_hreflang', function ( int $object_id ): void {
	my_cdn_purge_tags( [ 'archive-hreflang:' . $object_id ] );
} );

Source src/Cache/CacheInvalidator.php(opens GitHub in a new tab)

Environment Gates 1

Fires when the non-production gate blocks a dispatch - the observability hook for loggers or admin notices that surface “webhooks are intentionally muted on this clone.”

Parameters
ParameterDescription
string $feature-
string $environment-
Example3 lines
add_action( 'perflocale/dispatch/blocked', static function ( string $feature, string $environment ): void {
	error_log( "PerfLocale: {$feature} dispatch muted on {$environment} environment." );
}, 10, 2 );

Source src/Helper.php(opens GitHub in a new tab)

String Translation 1

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.

Parameters
ParameterDescription
int $string_idThe new string's ID.
string $old_textThe previous source text that was replaced.
string $new_textThe new source text.
string $domainText domain (e.g. perflocale, woocommerce).
string $contextTranslation context (e.g. email_subject_new_order, or an empty string when the string was registered without one).
Example4 lines
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 );

Source src/Database/Repository/StringRepository.php(opens GitHub in a new tab)

Addons & integrations 8

Fires once per migration step, before the addon's migrate_to() runs.

Parameters
ParameterDescription
AddonInterface&HasSchema $addon-
int $stored-
int $target-
Example3 lines
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 );

Source src/Addon/AddonSchemaManager.php(opens GitHub in a new tab)

Fires when an addon migration step throws.

The addon is not quarantined by this filter - observe or re-raise as needed.

Parameters
ParameterDescription
AddonInterface&HasSchema $addon-
int $version-
\Throwable $e-
Example3 lines
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 );

Source src/Addon/AddonSchemaManager.php(opens GitHub in a new tab)

Fires before an addon's stored data is purged.

Receives the computed PurgePlan snapshot.

Parameters
ParameterDescription
string $addon_id-
\PerfLocale\Addon\PurgePlan $plan-
Example3 lines
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 );

Source src/Addon/AddonUninstaller.php(opens GitHub in a new tab)

Fires once per batched DELETE during addon uninstall meta cleanup.

Parameters
ParameterDescription
string $type-
int $deleted-
int $total-
Example4 lines
add_action( 'perflocale/addon/meta_purge_batch', function ( string $type, int $deleted, int $total ): void {
	// $type is the OBJECT type: 'post' | 'user' | 'term' | 'comment'.
	// $deleted is this batch's row count, $total the running total for $type.
}, 10, 3 );

Source src/Addon/AddonUninstaller.php(opens GitHub in a new tab)

Addon settings UI 2

Fires after the addon subtab form-table, right before the submit button - use for secondary actions.

Parameters
ParameterDescription
string $subtab-
\PerfLocale\Settings $settings-
Example5 lines
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 );

Source src/Admin/Pages/SettingsPage.php(opens GitHub in a new tab)

Strings & content 3

Fires when PerfLocale is about to rebuild the files-mode translation bundles (WordPress 6.5+ .l10n.php files).

Two paths fire it, both only while string_translation_mode is files: a settings save that switches string translation to files mode, and a completed bulk string machine-translation job. Hook in to trigger downstream compilation or to piggyback on the same cache-clear window.

Parameters
ParameterDescription
\PerfLocale\Cache\CacheManager $cache-
Example3 lines
add_action( 'perflocale/strings/regenerate_files', function ( \PerfLocale\Cache\CacheManager $cache ): void {
	// Custom follow-up work.
} );

Source src/Cache/CacheInvalidator.php(opens GitHub in a new tab) src/Background/Jobs/BulkStringTranslateJob.php(opens GitHub in a new tab)

Fires whenever PerfLocale writes to the strings / string_translations tables, so addons that derive state from those tables can invalidate it.

Fires from three paths, each passing its origin: the admin Strings screen save (admin_save), a PO import (po_import, which in --replace mode bulk-deletes a language’s translations), and a completed bulk machine-translation job (bulk_mt).

Parameters
ParameterDescription
string $originwhat changed the strings (admin_save, po_import, or bulk_mt).
Example4 lines
add_action( 'perflocale/strings/changed', function ( string $origin ): void {
	// $origin is 'admin_save', 'po_import', or 'bulk_mt'.
	my_addon_invalidate_string_state( $origin );
} );

Source src/Admin/AdminController.php(opens GitHub in a new tab) src/Admin/PoSync.php(opens GitHub in a new tab) src/Background/Jobs/BulkStringTranslateJob.php(opens GitHub in a new tab)

Fires when PerfLocale cannot write a generated translation file (.mo/.l10n.php) to disk - a read-only uploads directory, a full disk, a hardened host.

The strings themselves are still served from the database, so this is a performance and portability signal rather than a breakage. Hook it to surface the condition in your own monitoring.

Parameters
ParameterDescription
string $pathFile that could not be written.
string $reasonWhy the write failed.
Example3 lines
add_action( 'perflocale/strings/file_write_failed', function ( string $path, string $reason ): void {
	error_log( "PerfLocale could not write {$path}: {$reason}" );
}, 10, 2 );

Source src/Strings/TranslationFileGenerator.php(opens GitHub in a new tab)

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. Translate to and Fill in from source translate every attribute in the chain that holds text and write each back to its own attribute. Fill in from source sends the chain to the server, so the original block is read through the same attributes.
  • string blockName - The block being inspected (e.g. core/paragraph, acme/quote-card).

Return: array. An empty / non-array return value is ignored and the toolbar falls back to [ 'content' ].
File: assets/js/block-toolbar.js