---
title: "Hooks Reference - All Filters and Actions - PerfLocale"
description: "Developer reference for every apply_filters and do_action hook in PerfLocale — 200+ filters and actions for tuning every seam of the plugin."
canonical: "https://perflocale.com/docs/hooks/"
source: "https://perflocale.com/docs/hooks/"
format: "markdown"
---
# Hooks Reference

Developer reference for all `apply_filters` and `do_action` hooks in PerfLocale.

 256

## Filters

### Settings & Configuration

#### `perflocale/translatable_post_types`

Filter the list of translatable post types.

```php
add_filter( 'perflocale/translatable_post_types', function ( array $post_types ): array {
	$post_types[] = 'product'; // Add WooCommerce products.
	return $post_types;
} );
```

**Parameters:** `array $post_types` - Array of post type slugs.  
**File:** `src/Settings.php`

#### `perflocale/translatable_taxonomies`

Filter the list of translatable taxonomies.

```php
add_filter( 'perflocale/translatable_taxonomies', function ( array $taxonomies ): array {
	$taxonomies[] = 'product_cat';
	return $taxonomies;
} );
```

**Parameters:** `array $taxonomies` - Array of taxonomy slugs.  
**File:** `src/Settings.php`

#### `perflocale/excluded_paths`

Filter URL paths excluded from language prefix injection.

```php
add_filter( 'perflocale/excluded_paths', function ( array $paths ): array {
	$paths[] = '/my-api/'; // Custom REST endpoint.
	$paths[] = '/sitemap.xml'; // Sitemap file.
	return $paths;
} );
```

**Parameters:** `array $paths` - Array of path prefixes (e.g., `'/wp-json/'`).  
**File:** `src/Settings.php`

#### `perflocale/cookie_lifetime`

Filter the language cookie lifetime in days.

```php
add_filter( 'perflocale/cookie_lifetime', function ( int $days ): int {
	return 30; // 30 days instead of default 365.
} );
```

**Parameters:** `int $days` - Cookie lifetime in days.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/strings/protected_domains`

Text domains whose strings the daily stale-string garbage collector must never delete. Backed by the persistent `perflocale_gc_protected_domains` option, so an addon's protection survives its own deactivation.

```php
add_filter( 'perflocale/strings/protected_domains', function ( array $domains ): array {
	$domains[] = 'my-plugin'; // Never GC my plugin's registered strings.
	return $domains;
} );
```

**Parameters:** `string[] $domains` - Protected text domains.  
**File:** `src/Database/Repository/StringRepository.php`

#### `perflocale/strings/po_export_batch_size`

Rows fetched per batch while streaming a PO export. Higher = fewer queries, more peak memory per batch. Clamped to 100–10,000.

```php
add_filter( 'perflocale/strings/po_export_batch_size', function ( int $batch_size ): int {
	return 5000; // Bigger batches on a memory-rich host.
} );
```

**Parameters:** `int $batch_size` - Rows per batch (default 1000).  
**File:** `src/Admin/PoSync.php`

#### `perflocale/site_health/string_blob_threshold`

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

```php
add_filter( 'perflocale/site_health/string_blob_threshold', function ( int $threshold ): int {
	return 40000; // Warn later on a memory-rich host.
} );
```

**Parameters:** `int $threshold` - Rows per language (default 20000).  
**File:** `src/Admin/SiteHealth.php`

#### `perflocale/site_health/probe_export_exposure`

Whether Site Health actively tests that the export directory is not web-readable. The check writes a short-lived random file into `wp-content/uploads/perflocale/exports/`, requests it over HTTP, and deletes it either way; the verdict is cached for an hour, and an inconclusive result for fifteen minutes.

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

```php
add_filter( 'perflocale/site_health/probe_export_exposure', '__return_false' );
```

**Parameters:** `bool $probe` - Whether to write the canary and make the request (default `true`).  
**File:** `src/Admin/SiteHealth.php`

#### `perflocale/switcher/add_to_menu`

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

```php
add_filter( 'perflocale/switcher/add_to_menu', function ( bool $add, $args ): bool {
	return $args->theme_location === 'primary'; // Only the primary menu.
}, 10, 2 );
```

**Parameters:** `bool $add`, `stdClass $args` (wp\_nav\_menu args).  
**File:** `src/Frontend/LanguageSwitcher.php`

#### `perflocale/active_languages`

Filter the list of active languages returned by the router.

```
// Hide a language on specific pages.
add_filter( 'perflocale/active_languages', function ( array $languages ): array {
	if ( is_page( 'internal-only' ) ) {
		return array_filter( $languages, fn( $l ) => $l->slug !== 'de' );
	}
	return $languages;
} );
```

**Parameters:** `array $languages` - Array of language objects.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/date_format`

Filter the date format that `perflocale()->date_format()` resolves for a given language. Default is the language row’s `date_format` field if set, else the WP `date_format` option. Lets a theme/addon override per-language without saving to the language row.

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

**Parameters:** `string $format`, `object|null $lang`.  
**File:** `src/Helper.php`

#### `perflocale/time_format`

Same shape as [`perflocale/date_format`](#perflocale-date-format), applied to the time format resolution path (`perflocale()->time_format()`).

**Parameters:** `string $format`, `object|null $lang`.  
**File:** `src/Helper.php`

### Translation Creation

#### `perflocale/translation/post_data`

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

```php
add_filter( 'perflocale/translation/post_data', function ( array $data, int $source_id, string $target_slug ): array {
	$data['post_author'] = get_current_user_id(); // Set current user as author.
	return $data;
}, 10, 3 );
```

**Parameters:**

-   `array $data` - Post data array (`post_type`, `post_status`, `post_title`, etc.).
-   `int $source_id` - Source post ID.
-   `string $target_slug` - Target language slug.

**File:** `src/Translation/PostTranslationManager.php`

#### `perflocale/translation/excluded_meta_keys`

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

```php
add_filter( 'perflocale/translation/excluded_meta_keys', function ( array $keys, int $source_id ): array {
	$keys[] = '_my_private_meta'; // Don't copy this to translations.
	return $keys;
}, 10, 2 );
```

**Parameters:**

-   `array $keys` - Meta key names to exclude.
-   `int $source_id` - Source post ID.

**File:** `src/Translation/PostTranslationManager.php`

#### `perflocale/translation/create_lock_ttl`

Lock TTL (seconds) for the `create_translation()` critical section that serialises concurrent translation creation for the SAME `(source_post, target_language)` pair. The default of 60s covers most real-world inserts including a meta + featured-image copy step that runs synchronously. Sites with slow `save_post` integrations (heavy SEO plugins, image regeneration on insert) can extend this so a legitimately-long insert doesn't lose its lock mid-flight.

```
// Bump to 5 minutes if your save_post chain is heavy (image regen, SEO, ACF Field Sync).
add_filter( 'perflocale/translation/create_lock_ttl', static fn(): int => 300 );
```

**Parameters:** `int $ttl` - Lock TTL in seconds. Default 60. Floor 5.  
**File:** `src/Translation/PostTranslationManager.php`

#### `perflocale/translation/create_term_lock_ttl`

Same as [`perflocale/translation/create_lock_ttl`](#perflocale-translation-create-lock-ttl) but for taxonomy term translations. Default is shorter (30s) because terms have no meta/image copy step.

```php
add_filter( 'perflocale/translation/create_term_lock_ttl', static fn(): int => 60 );
```

**Parameters:** `int $ttl` - Lock TTL in seconds. Default 30. Floor 5.  
**File:** `src/Translation/TermTranslationManager.php`

### Content Sync

#### `perflocale/sync_fields`

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

```php
// Add custom meta keys to sync programmatically.
add_filter( 'perflocale/sync_fields', function ( array $fields, string $post_type ): array {
	if ( $post_type === 'product' ) {
		$fields[] = '_price';
		$fields[] = '_sku';
		$fields[] = '_stock_status';
	}

	return $fields;
}, 10, 2 );
```

**Parameters:**

-   `array $fields` - Field names (built-in keys like `featured_image`, `menu_order`, or custom meta keys).
-   `string $post_type` - The post type being saved.

**Built-in field keys:** `featured_image`, `menu_order`, `post_parent`, `post_date`, `post_author`, `comment_status`, `ping_status`. Since 1.0.1 each of these is compared against the translation's current value first, and the translation is written only if something actually differs — so a save that changes nothing synced fires no `save_post` for the translations and leaves their modified dates alone. The `perflocale/cache/flush_object` purge signal still fires for each translation. Any other value is treated as a meta key: rows are read with `get_post_meta()` and written row by row with `delete_post_meta()` + `add_post_meta()`, so multi-value meta survives.

**File:** `src/Translation/ContentSync.php`

#### `perflocale/sync_fields/for_post`

Runs immediately after [`perflocale/sync_fields`](#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.

```php
add_filter( 'perflocale/sync_fields/for_post', function ( array $fields, int $post_id ): array {
	// Row 0 is already registered by the static field list, so add rows 1+.
	$rows = (int) get_post_meta( $post_id, 'my_repeater', true );
	for ( $i = 1; $i < $rows; $i++ ) {
		$fields[] = "my_repeater_{$i}_subfield";
	}
	return $fields;
}, 10, 2 );
```

**Parameters:** `array $sync_fields` - meta keys to sync; `int $post_id` - source post id.  
**Args:** 2  
**File:** `src/Translation/ContentSync.php`

#### `perflocale/sync/term_parent`

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

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

```
// Let editors pin individual categories out of the hierarchy mirror.
add_filter( 'perflocale/sync/term_parent', function ( bool $sync, WP_Term $source_term, int $sibling_id, string $taxonomy, int $translated_parent ): bool {
	if ( $taxonomy !== 'product_cat' ) {
		return $sync;
	}

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

**Parameters:**

-   `bool $sync` — `true` by default (mirror the hierarchy).
-   `WP_Term $source_term` — the term that was edited.
-   `int $sibling_id` — the translation sibling term about to be updated.
-   `string $taxonomy` — taxonomy slug.
-   `int $translated_parent` — the source term’s parent mapped into the sibling’s language (`0` = top level).

**Returns:** bool.  
**Args:** 5  
**File:** `src/Translation/ContentSync.php`

#### `perflocale/translatable_meta_keys`

Add meta keys to the auto-translated set without requiring the user to enter each one in _Settings → Translation_. Bundled addons (Yoast, ACF, page builders) use this hook to register their own meta keys; your custom plugin can do the same.

```php
add_filter( 'perflocale/translatable_meta_keys', function ( array $keys, string $post_type ): array {
	if ( $post_type === 'job_listing' ) {
		$keys[] = '_job_description';
		$keys[] = '_job_location';
	}
	return $keys;
}, 10, 2 );
```

**Parameters:** `array $keys`, `string $post_type` (may be empty for "any post type").  
**File:** `src/Settings.php`

#### `perflocale/media/show_featured_image_panel`

Whether the per-language _Featured Image_ metabox is registered on the post-edit screen for a given post type. Default `true`. Returning `false` suppresses the metabox **without** disabling the frontend featured-image swap (existing per-language overrides keep working) — useful for themes or addons that render their own featured-image surface and want to host the language overrides inside that UI instead. Only consulted for post types that support thumbnails and when the active theme declares `post-thumbnails` support.

```php
// Hide PerfLocale's metabox for products — a custom UI hosts the overrides.
add_filter( 'perflocale/media/show_featured_image_panel', static function ( bool $show, string $post_type ): bool {
	return $post_type === 'product' ? false : $show;
}, 10, 2 );
```

**Parameters:**

-   `bool $show` — `true` by default.
-   `string $post_type` — the post type being evaluated.

**Returns:** bool.  
**File:** `src/Translation/MediaTranslationManager.php`

#### `perflocale/media/featured_image_panel_languages`

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

```
// Only offer the site's two primary languages in the Featured Image panel.
add_filter( 'perflocale/media/featured_image_panel_languages', function ( array $languages, int $post_id, array $all_languages ): array {
	return array_filter( $languages, static fn( $lang ) => in_array( $lang->slug, [ 'en', 'de' ], true ) );
}, 10, 3 );
```

**Parameters:**

-   `array<int, object> $languages` — the languages PerfLocale would render.
-   `int $post_id` — the post being edited.
-   `array<int, object> $all_languages` — the unfiltered list of every active language.

**Returns:** array of language objects.  
**File:** `src/Translation/MediaTranslationManager.php`

### URL Handling

#### `perflocale/router/bot_ua_pattern`

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

```php
add_filter( 'perflocale/router/bot_ua_pattern', function ( string $pattern ): string {
	// Add a custom monitoring crawler to the bypass list.
	// substr( $pattern, 3, -4 ) strips the leading '/\b' and the trailing '\b/i',
	// leaving just the '(token|token|...)' group.
	return '/\b(MyMonitor|' . substr( $pattern, 3, -4 ) . ')\b/i';
} );
```

**Parameters:** `string $pattern` - Compiled regex (with delimiters and flags).  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/url/convert`

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

```php
add_filter( 'perflocale/url/convert', function ( string $url, string $target_slug, string $current_slug ): string {
	// Custom URL transformation.
	return $url;
}, 10, 3 );
```

**Parameters:**

-   `string $url` - The converted URL.
-   `string $target_slug` - Target language slug.
-   `string $current_slug` - Current language slug.

**File:** `src/Router/UrlConverter.php`

#### `perflocale/redirect_default_to_prefix`

When “Hide URL prefix for default language” is unchecked, the plugin 301-redirects bare URLs (e.g. `/` or `/contact/`) to the prefixed form (`/en/`, `/en/contact/`) to avoid duplicate content. Return `false` to disable that redirect and let both forms resolve.

```php
add_filter( 'perflocale/redirect_default_to_prefix', '__return_false' );
```

**Parameters:** `bool $enabled` - default `true`.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/redirect_prefix_to_default`

The mirror of [`perflocale/redirect_default_to_prefix`](#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.

```php
add_filter( 'perflocale/redirect_prefix_to_default', '__return_false' );
```

**Parameters:** `bool $enabled` - default `true`.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/url_mode`

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

```
// Force query-parameter mode on a staging box that can't rewrite.
add_filter( 'perflocale/url_mode', fn() => 'query' );
```

**Parameters:** `string $mode` - one of `subdirectory`, `subdomain`, `domain`, `query`.  
**File:** `src/Settings.php`

#### `perflocale/url/query_var`

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

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

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

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

```
// wp-content/mu-plugins/my-lang-var.php
// Leave `lang` to another plugin.
add_filter( 'perflocale/url/query_var', fn() => 'pfl_lang' );
```

**Parameters:** `string $query_var` - default `lang`.  
**File:** `src/Router/UrlConverter.php`

### SEO

#### `perflocale/seo/hreflang_tags`

Filter the full hreflang tag array (including the `x-default` entry) right before it’s emitted in `<head>` and on the `Link` HTTP header. Use it to add, remove, reorder, or mutate entries.

```
// Remove the x-default tag entirely.
add_filter( 'perflocale/seo/hreflang_tags', function ( array $tags ): array {
	return array_filter( $tags, fn( $t ) => $t['hreflang'] !== 'x-default' );
} );

// Add a regional alternate that PerfLocale doesn't manage itself.
add_filter( 'perflocale/seo/hreflang_tags', function ( array $tags ): array {
	$tags[] = [ 'hreflang' => 'en-ca', 'href' => 'https://ca.example.com/' ];
	return $tags;
} );
```

**Parameters:** `array $tags` - array of `['hreflang' => string, 'href' => string]`, computed per-request and cached.  
**File:** `src/Frontend/HreflangTags.php`

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

#### `perflocale/seo/hreflang_cache_ttl`

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

```
// Shorten archive hreflang caching to one hour.
add_filter( 'perflocale/seo/hreflang_cache_ttl', fn() => HOUR_IN_SECONDS );
```

**Parameters:** `int $ttl` - cache lifetime in seconds (default `12 * HOUR_IN_SECONDS`).  
**File:** `src/Frontend/HreflangTags.php`

#### `perflocale/seo/x_default_url`

Filter just the URL used for the `x-default` entry. Cleaner than walking the whole tag array when you only want to redirect search engines to a language-picker landing page or a specific regional variant. Return an empty string to suppress the `x-default` entry for the current request.

```
// Point x-default at a language-picker landing page.
add_filter( 'perflocale/seo/x_default_url', fn() => home_url( '/languages/' ) );

// Only override for the homepage; fall back to PerfLocale's default elsewhere.
add_filter( 'perflocale/seo/x_default_url', function ( string $url ): string {
	return is_front_page() ? home_url( '/choose-region/' ) : $url;
} );
```

**Parameters:**

-   `string $default_url` - URL currently slated for x-default (PerfLocale’s computed value).
-   `object $default` - default-language object (slug, locale, name, etc.).
-   `string $current_slug` - slug of the language currently being rendered.

**File:** `src/Frontend/HreflangTags.php`

#### `perflocale/seo/hreflang_include_fallbacks`

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

```
// Always advertise every active language, regardless of admin setting.
add_filter( 'perflocale/seo/hreflang_include_fallbacks', '__return_true' );

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

**Parameters:** `bool $enabled` - current setting value (defaults to `false`).  
**Since:** 1.0.0  
**File:** `src/Frontend/HreflangTags.php`

#### `perflocale/sitemap/inject_into_core`

Decide whether PerfLocale injects `xhtml:link` hreflang alternates into the WordPress core sitemap (`/wp-sitemap.xml`) on this request. The default decision is computed from the `seo_sitemap_source` setting:

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

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

```
// Force injection into WP core even though Yoast is active (sitemap disabled in Yoast).
add_filter( 'perflocale/sitemap/inject_into_core', '__return_true' );

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

**Parameters:** `bool $decision` - the pre-filter decision derived from the `seo_sitemap_source` setting.  
**File:** `src/Seo/SitemapIntegration.php`

#### `perflocale/sitemap/max_prime`

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

**Parameters:** `int $max` - Maximum objects to prime.  
**File:** `src/Seo/SitemapIntegration.php`

### Modern SEO & UX (1.0.0)

#### `perflocale/content_language/value`

Filter the BCP-47 locale emitted in the `Content-Language` HTTP response header. Default is the current language’s `locale` field normalised to hyphen-form (e.g. `de_DE` → `de-DE`); return an empty string to suppress the header on this request.

```php
add_filter( 'perflocale/content_language/value', function ( string $locale, $current_lang ): string {
	// Force the generic region-less code on specific templates.
	if ( is_page( 'global-press' ) ) {
		return 'en';
	}
	return $locale;
}, 10, 2 );
```

**Parameters:** `string $locale`, `object $current_lang` - the language object from the router.  
**File:** `src/Frontend/ContentLanguageHeader.php`

#### `perflocale/view_transitions/css`

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

```
// Add named transitions so the header and main content morph across pages.
add_filter( 'perflocale/view_transitions/css', function ( string $css ): string {
	return $css . 'header.site-header { view-transition-name: site-header; }
main { view-transition-name: main-content; }';
} );
```

**Parameters:** `string $css` - default CSS block (includes `@view-transition`, the 240ms crossfade rules, and the `prefers-reduced-motion` guard).  
**File:** `src/Frontend/ViewTransitionsEmitter.php`

#### `perflocale/view_transitions/should_emit`

Short-circuit the View Transitions emitter on specific templates. Useful when a theme runs its own GSAP/slider animations on navigation that would fight the browser transition.

```php
add_filter( 'perflocale/view_transitions/should_emit', function ( bool $should ): bool {
	if ( is_singular( 'portfolio' ) ) {
		return false; // GSAP page transitions are active on portfolio items.
	}
	return $should;
} );
```

**Parameters:** `bool $should_emit` - defaults to `true` when the setting is on.  
**File:** `src/Frontend/ViewTransitionsEmitter.php`

#### `perflocale/prerender/rules`

**Fallback path only (WP 6.4–6.7).** Filter the Speculation Rules JSON payload before emission. On WP 6.8+ use Core’s `wp_load_speculation_rules` action instead; our rule is registered via that API and is no longer filterable through this hook.

```php
add_filter( 'perflocale/prerender/rules', function ( array $rules, array $urls ): array {
	// Switch to prefetch instead of prerender on this site.
	if ( isset( $rules['prerender'] ) ) {
		$rules = [ 'prefetch' => $rules['prerender'] ];
	}
	return $rules;
}, 10, 2 );
```

**Parameters:** `array $rules`, `array $urls`.  
**File:** `src/Frontend/SpeculationRulesEmitter.php`

#### `perflocale/prerender/should_emit`

Short-circuit the Speculation Rules emitter on specific templates (applies to BOTH the WP 6.8+ Core API path and the WP < 6.8 fallback). Useful for pages where prerendering the switcher target would waste bandwidth - paginated archives with heavy media, or URLs that trigger non-idempotent GET side effects.

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

**Parameters:** `bool $should_emit`.  
**File:** `src/Frontend/SpeculationRulesEmitter.php`

#### `perflocale/prerender/use_core_api`

On WP 6.8+ PerfLocale registers its prerender rule via Core’s native Speculation Rules API (`wp_load_speculation_rules` action), so our rule lands inside Core’s single output script. Return `false` from this filter to force the fallback self-emit path instead - a standalone `<script type="speculationrules" id="perflocale-speculationrules">` emitted at `wp_footer` priority 20. Primarily useful for integrators who want the `perflocale/prerender/rules` filter (fallback-path only) to remain active on modern WP, or for test environments exercising the fallback code without downgrading WordPress.

```php
add_filter( 'perflocale/prerender/use_core_api', '__return_false' );
```

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

**Parameters:** `bool $use_core` - defaults to `true` when `\WP_Speculation_Rules` exists.  
**File:** `src/Frontend/SpeculationRulesEmitter.php`

#### `perflocale/fallback/wrap_nosnippet`

Filter whether to wrap singular-post content with `<div data-nosnippet>` when the visitor is viewing a non-default-language URL that’s showing the default-language post as fallback (`missing_translation_action = show_default`). Fires only when fallback is already detected; use this to exempt specific post types or IDs. The guard applies to both explicitly-linked default-language posts AND posts that have no translation-link entry (treated as default-language, matching the plugin-wide convention), so pages authored before PerfLocale was installed are protected too.

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

**Parameters:** `bool $wrap` (default `true`), `int $post_id`.  
**File:** `src/Translation/FallbackSnippetGuard.php`

#### `perflocale/fallback/nosnippet_tag`

Customise the HTML tag used to wrap fallback content. Default is `div`; use `span` for themes that render the content in an inline context where a block-level wrapper breaks layout. Must match `/^[a-z]{1,8}$/` - invalid values fall back to `div`.

```php
add_filter( 'perflocale/fallback/nosnippet_tag', fn( $tag ) => 'span' );
```

**Parameters:** `string $tag`, `int $post_id`.  
**File:** `src/Translation/FallbackSnippetGuard.php`

#### Core filter - applied by PerfLocale on both paths

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

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

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

### Language Switcher

#### `perflocale/switcher/languages`

Filter the languages available in the switcher.

**Parameters:** `array $languages` - Array of language objects.  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/output`

Filter the final HTML output of the language switcher.

**Parameters:**

-   `string $html` - Rendered HTML.
-   `array $args` - Switcher arguments.

**File:** `src/Frontend/LanguageSwitcher.php`

**Dev-mode guard:** a non-string return triggers `_doing_it_wrong()` and the renderer falls back to the unfiltered HTML.

#### `perflocale/switcher/link_attrs`

Filter the HTML attributes for each per-language link in the switcher - inline, dropdown, list, and flags renderers alike. Add analytics `data-*`, inject `rel` / `title`, override `hreflang` / `lang` / `dir`, or append additional CSS classes without forking the template.

```
// Add GA4 + Hotjar data attributes to every switcher link.
add_filter( 'perflocale/switcher/link_attrs', function ( array $attrs, object $lang, string $current_slug, array $args ): array {
	$attrs['data-lang'] = $lang->slug;
	$attrs['data-gtm-event'] = 'language_switch';
	$attrs['data-hj-track'] = 'lang-switcher';
	return $attrs;
}, 10, 4 );

// Add rel="alternate" for better SEO signal + hreflang override.
add_filter( 'perflocale/switcher/link_attrs', function ( array $attrs, object $lang ): array {
	$attrs['rel'] = 'alternate';
	$attrs['hreflang'] = $lang->locale ? str_replace( '_', '-', strtolower( $lang->locale ) ) : $lang->slug;
	return $attrs;
}, 10, 2 );
```

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

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

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

**Parameters:**

-   `array $attrs` - Attribute name => value pairs.
-   `object $lang` - Language object for this link (`slug`, `name`, `native_name`, `locale`, `flag`, `text_direction`).
-   `string $current_slug` - Currently active language slug.
-   `array $args` - Switcher arguments (template / display / layout / className …).

**Args:** 4  
**File:** `src/Frontend/LanguageSwitcher.php`

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

#### `perflocale/switcher/arrow_html`

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

```
// Use a Font Awesome icon instead of the inline SVG chevron.
add_filter( 'perflocale/switcher/arrow_html', function ( string $html, string $style ): string {
	return '<i class="fa-solid fa-chevron-down" aria-hidden="true"></i>';
}, 10, 2 );

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

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

**Parameters:**

-   `string $html` - Computed arrow HTML for `$style` (empty when `$style` is `none` or unknown).
-   `string $style` - Resolved style key (`single`, `double`, `none`, or any custom keyword a theme uses as a sentinel).

**Args:** 2  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/panel_before`

Inject HTML at the START of the dropdown panel, inside the `role="listbox"` container, before the first option. Useful for region groupings, search inputs, recently-used pickers, "Powered by …" notices, or any other panel chrome.

```php
add_filter( 'perflocale/switcher/panel_before', function ( string $html, array $languages, string $current_slug, array $attrs ): string {
	return '<div role="presentation" class="my-switcher-heading">' . esc_html__( 'Choose your language', 'my-theme' ) . '</div>';
}, 10, 4 );
```

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

**Parameters:**

-   `string $html` - HTML to inject (default empty).
-   `array $languages` - Languages about to be rendered (objects with `slug`, `name`, `native_name`, `locale`, `flag`, `text_direction`).
-   `string $current_slug` - Currently active language slug.
-   `array $attributes` - Resolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).

**Args:** 4  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/panel_after`

Inject HTML at the END of the dropdown panel, inside the `role="listbox"` container, after the last option. Same sanitisation, parameters, and role guidance as `panel_before` above.

```
// Add a "Manage languages" link visible only to admins.
add_filter( 'perflocale/switcher/panel_after', function ( string $html ): string {
	if ( ! current_user_can( 'manage_options' ) ) { return $html; }
	return '<a role="presentation" href="' . esc_url( admin_url( 'admin.php?page=perflocale-languages' ) ) . '">' . esc_html__( 'Manage languages', 'my-theme' ) . '</a>';
} );
```

**Parameters:** Same as `panel_before`.  
**Args:** 4  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/option_content`

Filter the inner HTML of each per-language switcher option — the content that sits between the wrapping `<a>` / `<span>` on every render surface (dropdown, inline, simple, list). Wraps the flag + label by default; the filter lets integrators inject region badges, mixed-direction `<bdi>` wraps, custom icons, script-tag indicators, etc., without forking the renderer. The same hook fires from both the dropdown and the inline / simple / list paths, so a single callback covers every option surface.

```php
// Append a region badge to every option label.
add_filter( 'perflocale/switcher/option_content', function ( string $inner, object $lang ): string {
	$region = $lang->slug === 'pt-br' ? 'Brasil'
	        : ( $lang->slug === 'pt'   ? 'Portugal' : '' );
	if ( $region === '' ) { return $inner; }
	return $inner . ' <span class="lang-region">· ' . esc_html( $region ) . '</span>';
}, 10, 2 );

// Wrap the label in <bdi> for safe mixed-direction rendering.
add_filter( 'perflocale/switcher/option_content', function ( string $inner ): string {
	return '<bdi>' . $inner . '</bdi>';
} );
```

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

**Parameters:**

-   `string $inner` - Default inner HTML (flag `<span>` + label `<span>`, either of which may be omitted depending on the `showFlags` / `showNames` / `style` settings).
-   `object $lang` - Language object for this option (`slug`, `name`, `native_name`, `locale`, `flag`, `text_direction`).
-   `string $current_slug` - Currently active language slug.
-   `array $attrs` - Resolved switcher attributes (display / nameFormat / triggerFormat / arrowStyle / className …).

**Args:** 4  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/kses_allowed_html`

Extend the `wp_kses` allowlist used to sanitize switcher HTML before addons echo it (via `LanguageSwitcherBlock::kses_switcher()`). Needed when a custom `arrow_html`, `panel_before`, `panel_after`, `option_content`, or `link_attrs` filter emits tags / attributes outside the switcher's built-in set (which covers `nav`, `button`, `div`, `span`, `a`, `svg`, `path` with their standard switcher attributes plus `lang`, `dir`, `aria-*`, `tabindex`, `id`).

```
// Analytics addon: allow data-tracking-id on options.
add_filter( 'perflocale/switcher/kses_allowed_html', function ( array $allowed ): array {
	$allowed['a']['data-tracking-id'] = true;
	return $allowed;
} );

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

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

**Parameters:**

-   `array $allowed` - Current allowlist (already merged with the post-context tags + the switcher's structural tags / attributes).
-   `string $html` - The HTML being sanitized, for context-aware decisions.

**Args:** 2  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

### Conditional Content

#### `perflocale/conditional_content/allowed_html`

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

```php
add_filter( 'perflocale/conditional_content/allowed_html', function ( array $allowed, string $content ): array {
	$allowed['custom-element'] = [ 'class' => true, 'data-config' => true ];
	return $allowed;
}, 10, 2 );
```

**Parameters:**

-   `array $allowed` - Allowed HTML map, standard `wp_kses_allowed_html` shape.
-   `string $content` - The rendered content being escaped, for context-aware decisions.

**Args:** 2  
**File:** `src/Frontend/ConditionalContent.php`

### String Translation

#### `perflocale/string/translate`

Filter a translated string before it is returned.

**Parameters:**

-   `string $translated` - The translated string.
-   `string $text` - The original string.
-   `string $domain` - Text domain.
-   `string $lang_slug` - Current language slug.

**File:** `src/Strings/StringTranslation.php`

#### `perflocale/strings/scanner/excluded_paths`

Filter directory patterns excluded from string scanning.

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

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

#### `perflocale/string/needs_update`

Fires when a registered string's source text changes and its existing translations are marked as needing review. Useful for triggering automated re-translation, sending notifications, or logging changes.

```php
add_action( 'perflocale/string/needs_update', function ( int $string_id, string $old_text, string $new_text, string $domain, string $context ): void {
	// Example: log the change or trigger auto-translation.
	error_log( "String #{$string_id} changed in {$domain}/{$context}" );
}, 10, 5 );
```

**Parameters:**

-   `int $string_id` - The new string's ID.
-   `string $old_text` - The previous source text that was replaced.
-   `string $new_text` - The new source text.
-   `string $domain` - Text domain (e.g. `perflocale`, `woocommerce`).
-   `string $context` - Translation context (e.g. `email_subject_new_order`, or an empty string when the string was registered without one).

**Args:** 5  
**File:** `src/Database/Repository/StringRepository.php`

### Machine Translation

#### `perflocale/machine_translation/providers`

Filter the registered machine translation providers.

**Parameters:** `array $providers` - Array of provider objects.  
**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/mt/pre_translate`

Filter texts before they are sent to a machine translation provider.

**Parameters:**

-   `array $texts` - Array of text strings.
-   `string $source_lang` - Source language code.
-   `string $target_lang` - Target language code.
-   `string $provider_id` - Provider identifier.

**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/mt/post_translate`

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

**Parameters:**

-   `array $translated` - Translated text strings.
-   `array $originals` - Original text strings.
-   `string $target_lang` - Target language code.
-   `string $provider_id` - Provider identifier.

**File:** `src/MachineTranslation/TranslationService.php`

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

#### `perflocale/mt/pre_translate_lookup`

Short-circuit a machine-translation call with your own cached translation. PerfLocale bundles no translation memory — this is the single hook point where an integration can answer the lookup itself. Return a non-empty string and it is used _verbatim_: the provider is never contacted, no provider quota is spent, and the REST response reports `"source": "tm"` instead of `"mt"`. Two caveats: the string is **not** passed through `wp_kses()` the way a provider response is, so return markup you already trust; and the request still counts against PerfLocale’s own hourly budgets ([`perflocale/mt/rate_limit`](#perflocale-mt-rate-limit), [`perflocale/mt/rate_limit_site`](#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.

```php
// Answer from a house translation memory before spending provider quota.
add_filter( 'perflocale/mt/pre_translate_lookup', function ( $translation, string $text, string $source_lang, string $target_lang ) {
	if ( is_string( $translation ) && $translation !== '' ) {
		return $translation; // Another integration already answered.
	}

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

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

**Parameters:**

-   `string|null $translation` — `null` by default (call the provider).
-   `string $text` — the source text.
-   `string $source_lang` — source language slug.
-   `string $target_lang` — target language slug.

**Returns:** a non-empty string to use as the translation; anything else (including `''`, `false`, or an array) falls through to the provider.  
**Args:** 4  
**File:** `src/Api/BlockTranslateController.php`

#### `perflocale/mt/should_auto_translate`

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

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

```
// A fast-moving feed post type is safe to re-machine-translate on every
// republish — except where an editor has taken ownership of the copy.
add_filter( 'perflocale/mt/should_auto_translate', function ( bool $should, int $post_id, string $lang_slug, int $existing_id ): bool {
	if ( get_post_type( $post_id ) !== 'market_feed' ) {
		return $should;
	}

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

	return true;
}, 10, 4 );
```

**Parameters:**

-   `bool $should` — the computed decision (`true` when the language has no translation or only an empty stub).
-   `int $post_id` — the source post being auto-translated.
-   `string $lang_slug` — the target language slug.
-   `int $existing_id` — the existing translation’s post ID, or `0` when none exists.

**Returns:** bool — `false` skips this language for this run.  
**Args:** 4  
**File:** `src/Bootstrap.php`

#### `perflocale/machine_translation/text_before_send`

Filter individual text before sending to a provider.

**Parameters:**

-   `string $text` - Text to translate.
-   `string $provider_id` - Provider identifier (e.g., `'deepl'`, `'google'`).
-   `string $target_lang` - Target language code.

**File:** `src/MachineTranslation/Provider/*.php`

#### `perflocale/machine_translation/result`

Filter individual translation result from a provider.

**Parameters:**

-   `string $translated` - Translated text.
-   `string $original` - Original text.
-   `string $provider_id` - Provider identifier.

**File:** `src/MachineTranslation/Provider/*.php`

#### `perflocale/mt/request_args`

Filter the `wp_remote_request()` arguments right before an outbound call to a machine-translation provider. Use it to raise the timeout for bulk jobs, inject proxy/tracing headers, set a custom user-agent, or route calls through a corporate gateway.

```
// 90-second timeout + corporate proxy + custom UA for the DeepL endpoint.
add_filter( 'perflocale/mt/request_args', function ( array $args, string $url, string $provider_id ): array {
	if ( $provider_id !== 'deepl' ) {
		return $args;
	}
	$args['timeout'] = 90;
	$args['user-agent'] = 'AcmeCorp-Translation/1.0';
	$args['headers']['X-Forwarded-Via'] = 'corp-gw';
	return $args;
}, 10, 3 );
```

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

**Parameters:**

-   `array $args` - `wp_remote_request()` argument array (timeout, headers, body, method, sslverify …).
-   `string $url` - Destination URL (already SSRF-validated).
-   `string $provider_id` - Provider slug (`deepl`, `google`, `microsoft`, `libretranslate`, `external_agency`, `wp_ai_client`).

**Args:** 3  
**File:** `src/MachineTranslation/AbstractProvider.php`

#### `perflocale/mt/trusted_hosts`

Filter the list of hostnames that skip the slow `gethostbyname()` step in PerfLocale's SSRF validator. Use it to register custom MT-provider hosts (for example a custom AI translator addon calling `api.cohere.ai` or your own self-hosted gateway) so their outbound requests don't hit the no-timeout DNS path.

```
// Allow a self-hosted AI gateway and Cohere's translate endpoint to skip
// the DNS step. Both must already resolve to public IPs - a host listed
// here skips every remaining SSRF gate, so only add names you control.
add_filter( 'perflocale/mt/trusted_hosts', function ( array $hosts ): array {
	$hosts[] = 'api.cohere.ai';
	$hosts[] = 'translate.acmecorp.internal-public.example';
	return $hosts;
} );
```

**Important:** this filter is a _performance fast-path_, and it is a genuine bypass of every check that follows it — treat it as a security-relevant setting. A host you add here returns from `validate_url()` immediately, so the IPv4 private/reserved-range gate, the IPv6 unique-local (`fc00::/7`) and link-local (`fe80::/10`) gates, the DNS resolution, and the resolved-IP private/loopback verification are all skipped for it. Only the hard-coded localhost list (`localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, `[::1]`) is checked before the filter and therefore still applies. Never add an IP literal, and only add hostnames you control and have verified resolve to public IPs. Entries are normalised (non-strings dropped, then lowercased); the return value is cast with `(array)`, so returning a non-array does _not_ restore the built-in list — a string becomes a one-element trusted list, and `null` leaves no trusted hosts at all, sending every request (including the five bundled providers) through the DNS step.

**Default trusted hosts** (built into PerfLocale) are exactly the five bundled MT-provider endpoints: `translation.googleapis.com`, `api.deepl.com`, `api-free.deepl.com`, `api.cognitive.microsofttranslator.com`, `libretranslate.com`. Anything else — a self-hosted LibreTranslate, a custom agency endpoint, an AI gateway — goes through the DNS step unless you add it here.

**Parameters:**

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

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

#### `perflocale/mt/retry_after_max_seconds`

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

```
// Never sleep more than a minute on a rate-limit backoff.
add_filter( 'perflocale/mt/retry_after_max_seconds', fn(): int => 60 );
```

**Parameters:** `int $max_seconds` - default 300.  
**File:** `src/MachineTranslation/AbstractProvider.php`

#### `perflocale/mt/pre_translate_text`

Filter the source text right before it is sent to the MT provider. Lets you mask placeholders, strip shortcodes, or pre-substitute branded terms.

```php
add_filter( 'perflocale/mt/pre_translate_text', function ( string $text, string $source_lang, string $target_lang, string $provider_id ): string {
	// Strip our internal {ATTR:foo} placeholders so the MT provider doesn't see them.
	return preg_replace( '/\{ATTR:[a-z_]+\}/', '', $text );
}, 10, 4 );
```

**Parameters:** `string $text`, `string $source_lang`, `string $target_lang`, `string $provider_id`.  
**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/mt/post_translate_text`

Filter the translated text after the MT provider returns it, and after the kses sanitisation pass. The fifth argument is the provider id so a single callback can branch by provider.

```php
add_filter( 'perflocale/mt/post_translate_text', function ( string $translated, string $source, string $source_lang, string $target_lang, string $provider_id ): string {
	// DeepL sometimes leaves a stray space before German punctuation; tidy it.
	if ( $provider_id === 'deepl' && $target_lang === 'de' ) {
		$translated = preg_replace( '/\s+([,.;:!?])/', '$1', $translated );
	}
	return $translated;
}, 10, 5 );
```

**Parameters:** `string $translated`, `string $source`, `string $source_lang`, `string $target_lang`, `string $provider_id`.  
**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/mt/agency_async_response`

Handle responses from the **External Agency** provider when the agency does not return a synchronous translation in the immediate POST response. PerfLocale does not ship a built-in async callback receiver; this filter is the supported extension point for addons that want to add one. The default return is `null`, which causes the provider to throw `RuntimeException` rather than silently fall back to source text (the pre-1.x behaviour, which corrupted translation posts).

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

```php
// Sketch of an out-of-tree async receiver: an addon registers its own
// REST route (e.g. POST /mycorp/v1/agency-callback) with HMAC verification,
// stashes incoming translations into an option/transient keyed by
// request_id, and matches them back here when the next translate() runs.
add_filter( 'perflocale/mt/agency_async_response', function (
	$translation,
	array $response,
	string $request_id,
	array $payload,
	array $context
) {
	$cached = get_option( 'mycorp_agency_cb_' . $request_id );
	if ( is_array( $cached ) && isset( $cached['translation'] ) ) {
		delete_option( 'mycorp_agency_cb_' . $request_id );
		return (string) $cached['translation'];
	}
	// No callback yet — let the core throw so the job fails loudly.
	return null;
}, 10, 5 );
```

**When this fires:** the agency returned a 2xx response but the decoded body has no `translation` string key. The previous behaviour was to silently return the source text and bill monthly characters; the current behaviour throws unless this filter supplies a string. See [Machine Translation → External Translation Agency](https://perflocale.com/docs/machine-translation/).

**Parameters:**

-   `string|null $translation` — Default `null` (causes the provider to throw).
-   `array $response` — Decoded agency response body.
-   `string $request_id` — UUIDv4 generated for this outbound request. Use it to correlate against a previously-received agency callback.
-   `array $payload` — Outbound payload sent to the agency: `text`, `source_lang`, `target_lang`, `request_id`.
-   `array $context` — Provider context: `source_lang`, `target_lang`, `provider_id` (always `external_agency`).

**Args:** 5  
**File:** `src/MachineTranslation/Provider/ExternalAgencyProvider.php`

#### `perflocale/mt/translatable_meta_keys`

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

```php
add_filter( 'perflocale/mt/translatable_meta_keys', function ( array $keys, string $post_type, int $post_id ): array {
	if ( $post_type === 'product' ) {
		$keys[] = '_my_product_tagline'; // A plain-text field.
	}
	return $keys;
}, 10, 3 );
```

**Parameters:** `array $keys` - Meta keys to machine-translate.  
`string $post_type` - Post type being translated.  
`int $post_id` - Source post ID (0 for type-level cost estimates).  
**File:** `src/MachineTranslation/MetaTranslator.php`

#### `perflocale/mt/meta_key_format`

Choose the machine-translation destination format for an individual meta key. Return `'text'` for plain-text meta (SEO title/description, plain custom fields) so the provider is called in text mode and the result is not entity-escaped; return `'html'` (the default) for markup-bearing meta. Unknown keys stay `'html'`, matching the historical behaviour. PerfLocale groups a post’s translatable meta by resolved format and makes one provider round-trip per format, so declaring a key as `text` keeps literal characters like `&` intact instead of turning them into `&amp;`. The bundled SEO and ACF/MetaBox/Pods addons use this hook to route plain-text fields to text mode.

```php
add_filter( 'perflocale/mt/meta_key_format', function ( string $format, string $key, string $post_type ): string {
	if ( $key === '_my_seo_title' ) {
		return 'text'; // Plain text - don't entity-escape.
	}
	return $format;
}, 10, 3 );
```

**Parameters:** `string $format` - `'html'` (default) or `'text'`.  
`string $key` - Meta key.  
`string $post_type` - Source post type.  
**File:** `src/MachineTranslation/MetaTranslator.php`

#### `perflocale/mt/string_translated`

Fires after each theme/plugin string is machine-translated and its row saved. Lets third-party code mark the row for review, push it into an external review queue, or log MT activity. Fires once per string per target language during a bulk string-translation job.

```php
add_action( 'perflocale/mt/string_translated', function ( int $string_id, int $target_lang_id, string $translation, string $source ): void {
	// Queue the freshly machine-translated string for human review.
	my_review_queue_add( $string_id, $target_lang_id );
}, 10, 4 );
```

**Parameters:**

-   `int $string_id` — the source string ID.
-   `int $target_lang_id` — the target language ID.
-   `string $translation` — the machine-translated text just saved.
-   `string $source` — the original source text.

**File:** `src/Background/Jobs/BulkStringTranslateJob.php`

#### `perflocale/sync/mirror_meta_keys`

Meta keys with MIRROR semantics during content sync: the sibling ends up holding exactly the source's rows, and an absent or empty source value clears the sibling. Rows identical to the source's are left alone rather than rewritten. Page-builder layout keys (Elementor, Bricks, Oxygen, Beaver Builder) use this. Keys not in this set get seed-only semantics — copied once when the sibling has no value, never overwritten. Mirror keys are also excluded from machine translation.

```php
add_filter( 'perflocale/sync/mirror_meta_keys', function ( array $keys, string $post_type ): array {
	$keys[] = '_my_builder_layout'; // Source-owned layout blob.
	return $keys;
}, 10, 2 );
```

**Parameters:** `array $keys` - Meta keys to mirror (defaults to the configured sync fields).  
`string $post_type` - Post type being synced.  
**File:** `src/Translation/ContentSync.php`

#### `perflocale/sync/after_mirror`

Fires after full-mirror meta keys have been written to a sibling translation. Page-builder addons hook this to invalidate the sibling’s GENERATED CSS/asset caches (for example Elementor’s `_elementor_css` / `_elementor_page_assets` meta) that a raw meta mirror can’t reach on its own — without it the translated page keeps enqueueing a stylesheet built from the old layout until a manual editor save or a site-wide regenerate. Only fires when at least one mirror key was actually written. PerfLocale’s bundled Elementor, Beaver Builder, and Oxygen addons consume it.

```php
add_action( 'perflocale/sync/after_mirror', function ( int $source_id, int $target_id, array $mirror_keys ): void {
	// Drop the sibling's generated CSS so it rebuilds from the mirrored layout.
	delete_post_meta( $target_id, '_elementor_css' );
}, 10, 3 );
```

**Parameters:** `int $source_id` - Source post ID.  
`int $target_id` - Sibling (target) post ID.  
`array<int, string> $mirror_keys` - Mirror meta keys just written to the sibling.  
**File:** `src/Translation/ContentSync.php`

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

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

#### `perflocale/mt/wp_ai_client_resolver`

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

```php
add_filter( 'perflocale/mt/wp_ai_client_resolver', function () {
	return function ( string $prompt, array $args ): string {
		// Route to your own gateway and return raw model text.
		return my_gateway_complete( $prompt, $args );
	};
} );
```

**Parameters:** `null|callable $resolver` - Callable accepting `(string $prompt, array $args)`. Return null to fall through to auto-detection.  
**File:** `src/MachineTranslation/Provider/WpAiClientProvider.php`

#### `perflocale/mt/wp_ai_client_prompt`

Customise the structured prompt sent to the AI Client per translation request. Keep the rules about placeholder preservation (the integrity gate enforces them) and the "output only the translation" instruction.

```php
add_filter( 'perflocale/mt/wp_ai_client_prompt', function ( string $prompt, string $text, string $source, string $target ): string {
	// Inject brand-voice guidance.
	return $prompt . "\n\nAlways translate as if writing for a friendly, conversational SaaS audience.";
}, 10, 4 );
```

**Parameters:** `string $prompt`, `string $text`, `string $source_lang`, `string $target_lang`.  
**File:** `src/MachineTranslation/Provider/WpAiClientProvider.php`

#### `perflocale/mt/wp_ai_client_args`

Filter the argument array passed to the AI Client call (capability, temperature, timeout, custom provider/model selectors).

```php
add_filter( 'perflocale/mt/wp_ai_client_args', function ( array $args, bool $fast_fail ): array {
	$args['provider'] = 'anthropic';
	$args['model']    = 'claude-opus-4-7';
	return $args;
}, 10, 2 );
```

**Parameters:** `array $args`, `bool $fast_fail`.  
**File:** `src/MachineTranslation/Provider/WpAiClientProvider.php`

#### `perflocale/mt/wp_ai_client_capability`

Filter the capability tag the AI Client should match. Defaults to `'text-generation'`. Set to `'translation'` or another capability tag if your AI gateway routes calls by capability.

```php
add_filter( 'perflocale/mt/wp_ai_client_capability', fn() => 'translation' );
```

**Parameters:** `string $capability`.  
**File:** `src/MachineTranslation/Provider/WpAiClientProvider.php`

#### `perflocale/switcher/auto_insert_anchor`

Retarget the Block Hooks auto-insertion of the language-switcher block to a different anchor. The default is `core/site-title:after`. Return a `[ block_type, position ]` pair, or false to keep the default.

```
// Put the switcher at the end of the site navigation instead of after the title.
add_filter( 'perflocale/switcher/auto_insert_anchor', fn() => [ 'core/navigation', 'last_child' ] );
```

**Parameters:** `array|false $override`, `string $anchor_block_type`, `string $relative_position`.  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/switcher/auto_insert_attrs`

Override the per-context default attributes the switcher carries when auto-inserted by Block Hooks. Defaults to `dropdown` display + compact 16-px flag for header context. Return an empty array to fall back to the global Settings → Language Switcher values.

```php
add_filter( 'perflocale/switcher/auto_insert_attrs', function ( array $attrs, string $anchor, string $position ): array {
	$attrs['style']    = 'flags_only';
	$attrs['flagSize'] = 14;
	return $attrs;
}, 10, 3 );
```

**Parameters:** `array $attrs`, `string $anchor_block_type`, `string $relative_position`, `array|null $parsed_anchor_block`.  
**File:** `src/Frontend/LanguageSwitcherBlock.php`

#### `perflocale/abilities/js_screens`

Extend the list of admin screens where the JS Abilities shim is enqueued. By default the shim ships on `post.php`, `post-new.php`, and `site-editor.php` — sites running a custom block-editor surface can opt that hook in here.

```php
add_filter( 'perflocale/abilities/js_screens', function ( array $hooks ): array {
	$hooks[] = 'toplevel_page_my-custom-editor';
	return $hooks;
} );
```

**Parameters:** `string[] $screens`.  
**File:** `src/Admin/Assets.php`

#### `perflocale/abilities/js_payload`

Filter the payload localised into the JS Abilities shim. Use this to remove specific abilities from JS-side registration (e.g. keep `translate-post` server-only) or to inject custom ability descriptors. The shape is `[ 'abilities' => [ { name, label, ... }, ... ] ]`.

```php
add_filter( 'perflocale/abilities/js_payload', function ( array $payload ): array {
	$payload['abilities'] = array_filter(
		$payload['abilities'] ?? [],
		fn( array $a ): bool => $a['name'] !== 'perflocale/translate-post'
	);
	return $payload;
} );
```

**Parameters:** `array $payload`.  
**File:** `src/Admin/Assets.php`

#### `perflocale/abilities/convert_url_permission`

Filter the permission gate for the `perflocale/convert-url` ability (its REST/MCP endpoint). Default requires a logged-in user. Only consulted when the Abilities API integration is enabled.

```
// Allow anonymous URL conversion through the ability.
add_filter( 'perflocale/abilities/convert_url_permission', '__return_true' );
```

**Parameters:** `bool $allowed` - default `is_user_logged_in()`.  
**File:** `src/AbilitiesRegistrar.php`

#### `perflocale/connectors/resolver`

Override the callable used to look up keys in the WordPress Connectors API. Useful for tests, custom in-house key vaults, or as a forward-compatibility shim if core renames the underlying function.

```php
add_filter( 'perflocale/connectors/resolver', function () {
	return function ( string $slug ): ?string {
		// Pull from your own secrets store.
		return MyVault::get_key( $slug );
	};
} );
```

**Parameters:** `null|callable $resolver`.  
**File:** `src/Settings.php`

#### `perflocale/connectors/slug_map`

Extend the setting-key → connector-slug map to route additional API keys through the Connectors API (the defaults cover the 4 MT providers only). PerfLocale ships no GeoIP or exchange-rate provider of its own, so any key those surfaces need is one your site registered — add it here to have the Connectors API broker it too. Unknown connector slugs simply return null from the resolver and fall through to env / constant / DB as usual.

```php
add_filter( 'perflocale/connectors/slug_map', function ( array $map ): array {
	// Setting keys registered by your own geo / FX provider wiring.
	$map['my_geo_provider_key'] = 'acme-geo';
	$map['my_fx_api_key']       = 'acme-fx';
	return $map;
} );
```

**Parameters:** `array<string, string> $map` - `perflocale_setting_key => connector_slug`.  
**File:** `src/Settings.php`

### Query Filtering

#### `perflocale/query/include_all_languages`

Override language filtering to include all languages in a query.

```php
add_filter( 'perflocale/query/include_all_languages', function ( bool $include, WP_Query $query ): bool {
	if ( $query->get( 'post_type' ) === 'faq' ) {
		return true; // Show FAQs in all languages.
	}
	return $include;
}, 10, 2 );
```

**Parameters:**

-   `bool $include` - Whether to include all languages (default `false`).
-   `WP_Query $query` - The query object.

**File:** `src/Translation/PostQueryFilter.php`

#### `perflocale/query/optimize_found_rows`

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

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

**Parameters:** `bool $enabled` (default `true`), `\WP_Query $query`.  
**Args:** 2  
**File:** `src/Translation/PostQueryFilter.php`

### Addons

#### `perflocale/addons/registered`

Filter the list of registered addons.

**Parameters:** `array $addons` - Array of addon definitions.  
**File:** `src/Addon/AddonRegistry.php`

**Dev-mode guard:** a return whose shape is not `array<string, AddonInterface>` triggers `_doing_it_wrong()` and the registry falls back to the unfiltered addon list.

#### `perflocale/addon/delete_data_on_uninstall`

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

```php
add_filter( 'perflocale/addon/delete_data_on_uninstall', function ( bool $delete, string $addon_id, $plan ): bool {
	// Always preserve the WooCommerce addon's data even if the user toggled global deletion on.
	if ( $addon_id === 'woocommerce' ) {
		return false;
	}
	return $delete;
}, 10, 3 );
```

**Parameters:** `bool $delete`, `string $addon_id`, `PerfLocale\Addon\PurgePlan $plan`.  
**File:** `src/Addon/AddonUninstaller.php`

#### `perflocale/addon/is_compatible`

Filter whether an addon is compatible with the current environment.

**Parameters:**

-   `bool $compatible` - Compatibility result.
-   `string $addon_id` - Addon identifier.

**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addons/quarantine_threshold`

How many consecutive boot failures an addon is allowed before the registry auto-quarantines it (skipping its `boot()` on subsequent requests). The default of `3` tolerates transient issues like a missing dependency during an upgrade window or a one-off OOM; raise it for flaky network-dependent addons, lower it to fail-fast in development.

```
// Raise tolerance for a known-flaky addon to 10 consecutive failures.
add_filter( 'perflocale/addons/quarantine_threshold', fn() => 10 );

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

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

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

**Parameters:** `int $threshold` — Default `3`. Min effective value `1`; values `≤ 0` disable quarantine.  
**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addon/manifest/check_soft_prefix_namespacing`

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

The notice is purely a DX hint — soft-prefix per-addon isolation is _not_ enforced (see [Hard vs soft prefixes](https://perflocale.com/docs/addon-system/#hard-vs-soft-prefixes)), and the global `perflocale_` prefix is the only namespace requirement. The notice is also `WP_DEBUG`\-gated by core, so production users never see it. Filter it off for addons that intentionally coordinate across the addon namespace (e.g. a manager addon cleaning up after a family of related sibling addons).

```
// Suppress for one specific coordinating addon.
add_filter( 'perflocale/addon/manifest/check_soft_prefix_namespacing', function ( bool $check, string $addon_id ): bool {
	return 'mycorp_coordinator' === $addon_id ? false : $check;
}, 10, 2 );

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

**Parameters:**

-   `bool $check` — Default `true`. Return `false` to skip the check for this addon.
-   `string $addon_id` — Identifier of the addon whose manifest is being written.

**Args:** 2  
**File:** `src/Addon/AddonManifestWriter.php`

### WooCommerce

#### `perflocale/woocommerce/exchange_rate_providers`

Register exchange-rate providers. PerfLocale ships the scheduled multi-currency sync mechanism but **no rate provider and no endpoint** — the built-in provider list is empty, so this filter (or [`exchange_rates_fetched`](#perflocale-woocommerce-exchange-rates-fetched)) is how a site supplies rates. Until one of them is wired, the sync contacts nothing.

**Parameters:** `array $providers` - Provider definitions (see [Exchange Rates docs](https://perflocale.com/docs/exchange-rates/)).  
**File:** `src/WooCommerce/ExchangeRateSync.php`

#### `perflocale/woocommerce/exchange_rates_fetched`

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

**Parameters:**

-   `array $rates` - Currency code => exchange rate.
-   `string $base_currency` - WooCommerce base currency code.
-   `string $provider_id` - Selected provider ID (empty when no provider is registered).

**File:** `src/WooCommerce/ExchangeRateSync.php`

#### `perflocale/woocommerce/inventory_sync_enabled`

Master switch for cross-language product-data sync, re-evaluated per request. Defaults to the _Sync Inventory Across Language Variants_ setting (`wc_sync_stock`, default on). Returning `false` suppresses every sibling write — product fields, variation fields, order-driven stock changes, and CSV-import syncs alike — without touching the stored setting, which makes it the right hook for a temporary freeze during a bulk operation.

This is the site-wide switch; use [`skip_inventory_sync`](#perflocale-woocommerce-skip-inventory-sync) to exempt a single product instead.

```
// Suspend inventory mirroring while a staged catalogue rebuild runs.
add_filter( 'perflocale/woocommerce/inventory_sync_enabled', function ( bool $enabled ): bool {
	return get_option( 'acme_catalog_rebuild_running' ) ? false : $enabled;
} );
```

**Parameters:** `bool $enabled` - The `wc_sync_stock` setting (default `true`).  
**Args:** 1  
**File:** `src/WooCommerce/InventorySync.php`

#### `perflocale/woocommerce/synced_product_fields`

Control which product meta keys are synced across language variants.

**Parameters:**

-   `array $fields` - Meta key list.
-   `int $product_id` - Product being synced.

**File:** `src/WooCommerce/InventorySync.php`

#### `perflocale/woocommerce/synced_variation_fields`

The variation-level twin of [`synced_product_fields`](#perflocale-woocommerce-synced-product-fields): controls which meta keys are copied from an edited variation onto the attribute-matched variation of every sibling-language parent. Siblings are located by anchoring on the linked parent product and matching children on their exact normalised attribute set — a variation whose attributes a translator has diverged is skipped rather than guessed at.

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

```
// Mirror a custom lead-time field across languages, but let each
// market keep its own SKUs.
add_filter( 'perflocale/woocommerce/synced_variation_fields', function ( array $fields, int $variation_id ): array {
	$fields[] = '_acme_lead_time_days';

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

**Parameters:**

-   `array $fields` - Variation meta keys to sync.
-   `int $variation_id` - The variation being saved.

**Args:** 2  
**File:** `src/WooCommerce/InventorySync.php`

#### `perflocale/woocommerce/sync_on_import`

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

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

```
// Skip per-row sync during CSV imports; resync in one pass afterwards.
add_filter( 'perflocale/woocommerce/sync_on_import', '__return_false' );

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

**Parameters:**

-   `bool $sync` - `true` by default.
-   `WC_Product $product` - The imported product or variation object.

**Args:** 2  
**File:** `src/WooCommerce/InventorySync.php`

#### `perflocale/woocommerce/skip_inventory_sync`

Skip inventory sync for a specific product.

**Parameters:**

-   `bool $skip` - Whether to skip inventory sync for this product. **Not** a 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_id` - Product being synced.

**File:** `src/WooCommerce/InventorySync.php`

#### `perflocale/woocommerce/translate_string`

Override string translation for WooCommerce gateway/shipping titles.

**Parameters:**

-   `string|null $translated` - Translated string or null (no override).
-   `string $original` - Original string.
-   `string $slug` - Current language slug.
-   `string $context` - Translation context (gateway ID or empty).

**Args:** 4  
**File:** `src/WooCommerce/WcStringTranslation.php`

#### `perflocale/woocommerce/translatable_email_ids`

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

```php
add_filter( 'perflocale/woocommerce/translatable_email_ids', function ( array $ids ): array {
	$ids[] = 'customer_stock_notification'; // Add a custom WC email type.
	return $ids;
} );
```

**Parameters:** `array $ids` - Array of WooCommerce email ID strings.  
**Default:** `new_order`, `cancelled_order`, `failed_order`, `customer_on_hold_order`, `customer_processing_order`, `customer_completed_order`, `customer_refunded_order`, `customer_invoice`, `customer_note`  
**Args:** 1  
**File:** `src/WooCommerce/EmailTranslation.php`

### Gravity Forms

#### `perflocale/gravity_forms/form_translations`

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

```php
add_filter( 'perflocale/gravity_forms/form_translations', function ( array $translations, int $form_id, string $language_slug ): array {
	if ( $form_id === 3 && $language_slug === 'de' ) {
		$translations['fields']['1']['label'] = 'Vollständiger Name';
	}
	return $translations;
}, 10, 3 );
```

**Parameters:** `array $translations` - Stored translations for this form + language (empty when none). Shape: `[ 'fields' => [ field_id => [ 'label', 'description', 'placeholder', 'choices' ] ], 'confirmations' => [ id => [ 'message', 'url' ] ] ]`.  
`int $form_id` - Gravity Forms form id.  
`string $language_slug` - Current PerfLocale language slug.  
**File:** `addons/gravity-forms/PerfLocaleGravityForms.php`

### GeoIP Redirect

#### `perflocale/redirect/priority_order`

Per-request override of the saved redirect priority order. Receives the cleaned, sanitised order array — useful for forcing `geo` first for staff cookie holders, or falling back to `browser` while a third-party geo provider is rate-limited.

```php
add_filter( 'perflocale/redirect/priority_order', function ( array $order ): array {
	if ( isset( $_COOKIE['acme_staff'] ) ) {
		// Trust GeoIP first for the team, then fall back to browser language.
		return [ 'geo', 'browser', 'edge_hint' ];
	}
	return $order;
} );
```

**Parameters:** `array $order` - Sanitised priority array. Known sources: `geo`, `browser`, `edge_hint` (any other slug is dropped by the sanitiser). Default order: `geo, browser, edge_hint`.  
**File:** `src/Settings.php`

#### `perflocale/geo/providers`

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

```php
add_filter( 'perflocale/geo/providers', function ( array $providers ): array {
	$providers['my_provider'] = [
		'name' => 'My GeoIP Provider',
		'needs_key' => true,
		'key_setting' => 'geo_my_provider_key',
		'fetch_callback' => function ( string $ip, \PerfLocale\Settings $settings ): string {
			// Return two-letter country code or ''.
			return 'US';
		},
	];
	return $providers;
} );
```

**Parameters:** `array $providers` - Associative array of provider definitions.  
**File:** `src/Router/GeoRedirect.php`

#### `perflocale/geo/country_code`

Filter the detected country code after a GeoIP lookup.

**Parameters:** `string $country_code`, `string $ip`  
**File:** `src/Router/GeoRedirect.php`

#### `perflocale/geo/redirect_language`

Override the language slug selected for redirect.

**Parameters:** `string $language_slug`, `string $country_code`, `string $ip`  
**File:** `src/Router/GeoRedirect.php`

#### `perflocale/geo/country_map`

Filter the country-to-language mapping array.

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

#### `perflocale/geo/should_redirect`

Final short-circuit before the outbound IP-lookup HTTP request is made. Return `false` to skip GeoIP redirection entirely for the current request - no provider call is issued. Complements the existing [`perflocale/privacy/consent_given`](#perflocale-privacy-consent-given) gate.

```php
// Skip GeoIP redirects for logged-in users, admin URLs, and affiliate campaigns.
add_filter( 'perflocale/geo/should_redirect', function ( bool $should ): bool {
	if ( is_user_logged_in() ) {
		return false; // Respect the visitor’s saved preference.
	}
	if ( ! empty( $_GET['utm_campaign'] ) ) {
		return false; // Don’t second-guess campaign traffic.
	}
	$path = wp_parse_url( $_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH ) ?: '/';
	if ( str_starts_with( $path, '/api/' ) || str_starts_with( $path, '/webhook/' ) ) {
		return false;
	}
	return $should;
} );
```

**Parameters:** `bool $should_redirect` - default `true`.  
**File:** `src/Router/GeoRedirect.php`

#### `perflocale/fallback/redirect_status`

HTTP status code used when a [language fallback](https://perflocale.com/docs/language-fallbacks/) redirect fires. Default `302` (temporary). Return `301` for permanent SEO consolidation. Allowed values: `{301, 302, 307, 308}`; anything else silently reverts to `302`.

```php
// Permanent redirects for en_US → en_GB specifically.
add_filter( 'perflocale/fallback/redirect_status', function ( int $status, string $from, string $to ): int {
	return ( $from === 'en-us' && $to === 'en-gb' ) ? 301 : $status;
}, 10, 3 );
```

**Parameters:**

-   `int $status` - default 302.
-   `string $from_slug` - current (missing) language slug.
-   `string $to_slug` - resolved fallback language slug.
-   `int $from_post_id` - source post being rendered.
-   `int $to_post_id` - target post ID (`0` when redirecting to the language homepage).

**File:** `src/Translation/PostQueryFilter.php`

### Privacy

#### `perflocale/privacy/consent_given`

Gate for PerfLocale’s `perflocale_lang` cookie and its automatic GeoIP + browser-language redirects. Return `false` to suppress the cookie and both redirects until the visitor has granted consent. Default `true` - they fire immediately, matching the pre-consent-framework behaviour. (To drop the cookie unconditionally without a consent plugin, use the Cookieless-mode setting instead.)

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

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

**Parameters:** `bool $granted` - default `true`.  
**File:** `src/Router/LanguageRouter.php`, `src/Router/GeoRedirect.php`

### REST API

#### `perflocale/api/config`

Filter the JSON payload returned by `GET /perflocale/v1/config` before it is cached and served to edge runtimes (Cloudflare Worker, Vercel Edge, Netlify Edge, …). Use it to carry feature flags, per-language A/B variants, fallback chains, or any routing metadata the edge needs without an extra origin round-trip.

```
// Expose feature flags + fallback chain to the edge.
add_filter( 'perflocale/api/config', function ( array $payload ): array {
	$payload['feature_flags'] = [
		'new_checkout' => get_option( 'acme_new_checkout_enabled', false ),
		'promo_banner' => get_option( 'acme_promo_banner_active', false ),
	];
	$payload['fallback_chain'] = [
		'en-gb' => [ 'en-us', 'en' ],
		'de-at' => [ 'de', 'en' ],
	];
	return $payload;
} );
```

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

**Parameters:** `array $payload` - The full config payload (version, url\_mode, url\_prefix\_type, default\_slug, hide\_default\_prefix, excluded\_paths, detection\_order, edge\_hint\_header, edge\_hint\_cookie, languages).  
**File:** `src/Api/ConfigController.php`

#### `perflocale/api/languages_public`

Whether the public read endpoints `GET /perflocale/v1/languages` and `GET /perflocale/v1/languages/{slug}` may be accessed anonymously. Default `true` - the payload is a strict subset of what is already rendered to visitors (switcher, `hreflang` tags, URL structure), so there is nothing private to protect. Return `false` to require the `read` capability (any logged-in user) instead. Write endpoints are unaffected - they always require `perflocale_manage_languages`.

```
// Block anonymous reads of /languages.
add_filter( 'perflocale/api/languages_public', '__return_false' );
```

**Parameters:**

-   `bool $public` - default `true`.
-   `WP_REST_Request $request` - current request.

**Args:** 2  
**File:** `src/Api/LanguagesController.php`

#### `perflocale/edge_worker/config_permission_callback`

Gate for the public read endpoint `GET /wp-json/perflocale/v1/config` (consumed by the Cloudflare Worker / Vercel Edge / Netlify Edge integrations). Default `true` — public-read, because the payload is a strict subset of what is already rendered to visitors (URL mode, default slug, active languages, edge-hint header / cookie names). Return `false` or a `WP_Error` to require authentication; return `true` to keep public-read. Symmetric with [`perflocale/api/languages_public`](#perflocale-api-languages-public) for the `/languages` endpoint — write endpoints are unaffected and always require `perflocale_manage_languages`.

```php
// Require admin auth to fetch the edge config.
add_filter( 'perflocale/edge_worker/config_permission_callback', static fn() => current_user_can( 'manage_options' ) );

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

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

**Parameters:**

-   `bool|WP_Error $allowed` — default `true`.
-   `WP_REST_Request $request` — current request.

**Args:** 2  
**File:** `src/Api/ConfigController.php`

### Admin

#### `perflocale/admin/filter_terms_checklist`

Whether the post-edit Categories / Tags checklist should be filtered to the current language. Default `true` when 2+ languages are active. Return `false` to show every term across every language in the metabox (e.g. for a custom integration that re-scopes terms in JS).

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

**Parameters:** `bool $enabled`, `int $post_id`, `array $args`.  
**File:** `src/Admin/MetaBox.php`

#### `perflocale/admin/post_list_columns`

Filter the columns added to the post list table.

**Parameters:**

-   `array $columns` - Column definitions.
-   `string $post_type` - Current post type.

**File:** `src/Admin/PostListColumns.php`

#### `perflocale/predefined_languages`

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

Each entry must be an associative array with these keys:

-   `slug` - URL-safe slug. Maximum 10 characters, must be unique across all entries.
-   `locale` - WordPress locale (e.g. `en_US`, `fr_FR`). Maximum 20 characters, must be unique.
-   `name` - English display name shown in the admin picker.
-   `native_name` - Native-script display name shown alongside the English name.
-   `flag` - ISO 3166-1 alpha-2 country code (lowercase, e.g. `us`, `fr`); used to render the flag emoji. Empty string is allowed for languages without an obvious flag.
-   `text_direction` - `'ltr'` or `'rtl'`.
-   `date_format` - PHP `date()` format string. Often locale-specific (e.g. `'j F Y'` for European day-month-year).
-   `time_format` - PHP `date()` format string (e.g. `'g:i a'`, `'H:i'`).

**Add a custom language:**

```php
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):

```php
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):

```php
add_filter( 'perflocale/predefined_languages', function ( array $list ): array {
	foreach ( $list as &$entry ) {
		if ( ( $entry['slug'] ?? '' ) === 'de' ) {
			$entry['date_format'] = 'Y-m-d';
		}
	}
	return $list;
} );
```

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

**Parameters:** `array<int, array<string, string>> $predefined` - Bundled languages as loaded from `data/languages.php` (190+ entries in 1.0.0).  
**File:** `src/Admin/Pages/LanguagesPage.php`

### Roles & Permissions

#### `perflocale/roles/editor_caps`

Filter the capabilities granted to the **Editor** role on plugin activation. Return an empty array to prevent Editors from receiving any PerfLocale capabilities. Return a subset to restrict them to specific ones. The Administrator role is not affected.

**Parameters:** `array<string, bool> $caps` - Map of `capability => grant`. Default: `perflocale_translate`, `perflocale_manage_translations`, `perflocale_approve_translations`, `perflocale_use_mt`.  
**File:** `src/Admin/TranslatorRole.php`

```
// Remove ALL PerfLocale capabilities from the Editor role.
add_filter( 'perflocale/roles/editor_caps', '__return_empty_array' );

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

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

#### `perflocale/roles/cap_roles`

Filter which WordPress roles have PerfLocale capabilities **removed** on plugin deactivation or uninstall. Fires in two places: `TranslatorRole::remove_roles()` (deactivation) and `SiteCleanup::strip_role_and_caps()`, which `uninstall.php` reaches through `SiteCleanup::purge_current_site()` on both the full-wipe and preserve-data branches.

**Parameters:** `string[] $roles` - Array of role slugs. Default: `['administrator', 'editor']` on deactivation, `['editor', 'administrator']` on uninstall (same set, different order).  
**File:** `src/Admin/TranslatorRole.php`, `src/Database/SiteCleanup.php`

```
// Do not strip caps from the Editor role on deactivation/uninstall.
add_filter( 'perflocale/roles/cap_roles', function ( array $roles ): array {
	return array_diff( $roles, [ 'editor' ] );
} );

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

### Performance & Caching

#### `perflocale/cache/eager_map_row_cap`

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

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

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

**Parameters:** `int $cap` (default `2000`), `\PerfLocale\Enum\ObjectType $type`.  
**File:** `src/Database/Repository/TranslationGroupRepository.php`

#### `perflocale/cache/eager_map_byte_cap`

Defensive byte-size gate for the same eager-link-map option. After the row cap passes, the serialised array is measured one more time — if it exceeds this byte cap the `'too_large'` sentinel is written instead. Catches the edge case of an under-the-row-cap map whose individual rows carry unusually large fields (long language slugs, custom addon columns, etc.).

Default **768000 bytes** (750 KB). Tune in tandem with the row cap on hosts with non-default `alloptions` sizing.

```php
add_filter( 'perflocale/cache/eager_map_byte_cap', static fn (): int => 1024 * 1024 ); // 1 MB ceiling
```

**Parameters:** `int $cap` (default `768000`), `\PerfLocale\Enum\ObjectType $type`.  
**File:** `src/Database/Repository/TranslationGroupRepository.php`

### Background Jobs

Filters that tune the background-processing system. See the [Background Jobs](https://perflocale.com/docs/background-jobs/) doc for the full feature reference.

#### `perflocale/jobs/threshold/<type>`

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

```php
// Force string scans to ALWAYS run async, regardless of file count.
add_filter( 'perflocale/jobs/threshold/string_scan', static fn(): int => 0 );

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

**Parameters:** `int $base` (resolved from settings or default), `array $args` (the dispatch args).  
**File:** `src/Background/AbstractJob.php`

#### `perflocale/jobs/max_attempts`

Maximum retry count before a failed job stops being rescheduled. Default 5 (initial attempt + 4 retries).

```php
add_filter( 'perflocale/jobs/max_attempts', static fn(): int => 1 ); // one-shot, never retry
```

**Parameters:** `int $max`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/retry_delay`

Seconds to wait before the next retry attempt. Default: exponential backoff capped at 1 hour — `min(3600, 60 * 2^(attempts-1))`.

```php
add_filter( 'perflocale/jobs/retry_delay', static function ( int $delay, int $attempts ): int {
	return 60 * max( 1, $attempts ); // linear backoff
}, 10, 2 );
```

**Parameters:** `int $delay`, `int $attempts`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/max_concurrent/<type>`

Per-type concurrency cap. Default 1 (one worker per type at a time). Set > 1 to allow parallel workers; `PHP_INT_MAX` disables the cap entirely.

```
// Allow up to 4 parallel string scans.
add_filter( 'perflocale/jobs/max_concurrent/string_scan', static fn(): int => 4 );
```

**Parameters:** `int $max`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/deduplicate_admission`

Whether an identical job already queued or running blocks a new dispatch. Default `true`. “Identical” means the same job type, byte-identical arguments and the same site — the logical operation, not merely the type — so unrelated work of the same type still runs in parallel, and a chunked chain (whose cursor advances on every link) is never mistaken for a repeat of itself. When a twin is found, `Dispatcher::enqueue()` returns that job’s ID with `duplicate => true` instead of queueing a second one.

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

**Parameters:** `bool $enabled`, `string $type`, `array $args`.  
**File:** `src/Background/Dispatcher.php`

#### `perflocale/jobs/max_args_bytes`

Maximum JSON-encoded size of the `args` payload accepted by `Dispatcher::enqueue()`. Default 100 KB. Hardening against options-table bloat.

**Parameters:** `int $bytes` (clamped to a minimum of 1024).  
**File:** `src/Background/Dispatcher.php`

#### `perflocale/jobs/max_args_depth`

Maximum array-nesting depth of the `args` payload accepted by `Dispatcher::enqueue()`. Default 20. A payload can fit under the byte cap ([`max_args_bytes`](#perflocale-jobs-max-args-bytes)) yet still be pathologically deep — deeply-nested arrays trip PHP’s serializer (which WP uses to write `option_value`) and can blow `xdebug.max_nesting_level`. Args deeper than this are rejected before serialisation. Floor-clamped to 4, so any legitimate job-args shape is safe.

```
// A custom job that ships a genuinely nested config tree.
add_filter( 'perflocale/jobs/max_args_depth', static fn(): int => 40 );
```

**Parameters:** `int $depth` (default 20, clamped to a minimum of 4).  
**File:** `src/Background/Dispatcher.php`

#### `perflocale/jobs/active_index_max`

Cap on the active-jobs index size (the rows visible on **PerfLocale → Jobs**). Default 50. Each row is ~200 bytes, so a cap of 250 costs ~50 KB in a non-autoloaded option. Floor-clamped to 10.

```
// Sites with very high dispatch throughput can show more history.
add_filter( 'perflocale/jobs/active_index_max', static fn(): int => 250 );
```

**Parameters:** `int $max` (default 50, clamped to 10 minimum).  
**File:** `src/Background/JobState.php`

#### `perflocale/jobs/resume_max`

Upper bound on how many queued/running jobs the Resumer re-dispatches in one sweep (e.g. after a deactivate/reactivate). Default 1000 — far above the active-display cap so pending jobs aren’t silently dropped, yet bounded so a runaway jobs table can’t OOM the sweep. Floor-clamped to 1.

```php
add_filter( 'perflocale/jobs/resume_max', static fn(): int => 5000 );
```

**Parameters:** `int $max` (default 1000, clamped to 1 minimum).  
**File:** `src/Background/JobState.php`

#### `perflocale/import/max_file_bytes`

Maximum size of the uploaded JSON import envelope. Default 52428800 (50 MB). Floor-clamped to 1 MB.

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

**Parameters:** `int $bytes`.  
**File:** `src/Admin/AdminController.php`

#### `perflocale/export/batch_size`

Rows fetched per LIMIT clause during the streaming JSON export. Default 1000. Bottleneck is usually `wp_json_encode`, not the SELECT. Clamped to 50–10000. Per-table override via the second arg.

```php
add_filter( 'perflocale/export/batch_size', static function ( int $size, string $table ): int {
	return $table === 'strings' ? 5000 : 1000;
}, 10, 2 );
```

**Parameters:** `int $size`, `string $table`.  
**File:** `src/Admin/DataExporter.php`

#### `perflocale/migration/translatepress/batch_size`

Posts processed per transaction during the TranslatePress migration. Default 50. Clamped to 5–500.

```php
add_filter( 'perflocale/migration/translatepress/batch_size', static fn(): int => 200 );
```

**Parameters:** `int $size`.  
**File:** `src/Migration/TranslatePressImporter.php`

#### `perflocale/migration/wpml/batch_size`

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

```php
add_filter( 'perflocale/migration/wpml/batch_size', static fn(): int => 250 );
```

**Parameters:** `int $size`.  
**File:** `src/Migration/WpmlImporter.php`

#### `perflocale/migration/polylang/batch_size`

Number of `post_translations` / `term_translations` taxonomy terms fetched per SELECT during the Polylang migration. The importer first lists every term\_id (cheap), chunks them by this filter, and only then fetches the `description` payload (a serialized PHP array that `maybe_unserialize` expands into N slots in memory per row). Lowering the value reduces peak memory on very large sites at the cost of more SQL roundtrips. Default 100. Clamped to 10–1000.

```php
add_filter( 'perflocale/migration/polylang/batch_size', static fn(): int => 250 );
```

**Parameters:** `int $size`.  
**File:** `src/Migration/PolylangImporter.php`

#### `perflocale/migration/wpml_string_batch_size`

Rows fetched per keyset-paginated batch when importing string translations from WPML's `icl_string_translations` table. The importer streams via `WHERE s.id > $last_id ORDER BY s.id ASC LIMIT N` so peak memory stays bounded at one batch regardless of total row count — on a 50k-string WPML export the previous one-shot SELECT consumed ~25 MB of PHP arrays before any work began. Lowering the value trades fewer SQL roundtrips for smaller memory peaks; raising it does the opposite. Default 500. Floor-clamped to 1 (anything lower reverts to the default).

```php
add_filter( 'perflocale/migration/wpml_string_batch_size', static fn(): int => 1000 );
```

**Parameters:** `int $size`.  
**File:** `src/Migration/WpmlImporter.php`

#### `perflocale/migration/translatepress/gettext_batch_size`

Rows fetched per keyset-paginated batch when importing gettext string translations from TranslatePress's per-language `trp_gettext_*` tables. Streams via `WHERE g.original_id > $last_id ORDER BY g.original_id ASC LIMIT N`; previously the importer used a flat `LIMIT 10000` that both capped peak memory at ~5 MB per language AND silently truncated sites with more than 10k gettext strings per language. The keyset cursor removes the silent-truncation failure mode entirely; lowering the batch size further reduces memory at the cost of more SQL roundtrips. Default 1000. Floor-clamped to 100.

```php
add_filter( 'perflocale/migration/translatepress/gettext_batch_size', static fn(): int => 2500 );
```

**Parameters:** `int $size`.  
**File:** `src/Migration/TranslatePressImporter.php`

#### `perflocale/migration/translatepress/min_status`

Minimum TranslatePress translation status to import from its dictionary tables. Default `1` (machine-translated) so the migration faithfully reproduces what the source site actually displayed; return `2` to import only human-reviewed translations.

```
// Import only human-reviewed TranslatePress translations.
add_filter( 'perflocale/migration/translatepress/min_status', static fn(): int => 2 );
```

**Parameters:** `int $status` - Default `1` (machine-translated). Set to `2` for reviewed-only.  
**File:** `src/Migration/TranslatePressImporter.php`

#### `perflocale/strings/scanner/max_file_bytes`

Maximum size of a single PHP file the string scanner will read. Files larger than this are skipped to keep peak memory bounded. Default 2097152 (2 MB). Floor-clamped to 64 KB.

```php
add_filter( 'perflocale/strings/scanner/max_file_bytes', static fn(): int => 10 * 1024 * 1024 ); // 10 MB
```

**Parameters:** `int $bytes`.  
**File:** `src/Strings/StringScanner.php`

#### `perflocale/strings/scanner/batch_size`

Strings buffered in memory before flushing to the DB via bulk\_insert. Default 500. Clamped to 50–5000. A `$batch_size` passed to `StringScanner::scan()` is only the filter’s _default_ value — a registered callback overrides it for every caller, including the StringScan background job (which always passes an explicit size: `500` unless its `batch_size` job arg says otherwise).

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

**Parameters:** `int $size`.  
**File:** `src/Strings/StringScanner.php`

#### `perflocale/jobs/stuck_timeout_seconds`

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

**Parameters:** `int $seconds`.  
**File:** `src/Background/JobState.php`

#### `perflocale/jobs/pause_recheck_seconds`

When the queue is paused, workers that pick up a job re-schedule it this many seconds later instead of running. Default 300 (5 minutes).

**Parameters:** `int $seconds`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/pause_refresh_window`

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

**Parameters:** `int $seconds` (default 10, minimum 1).  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/type_busy_retry_seconds`

When the per-type concurrency lock is held by another worker, the new attempt re-queues this many seconds later. Default 60.

**Parameters:** `int $seconds`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/type_busy_max_seconds`

Upper bound on the delay between type-busy retries. When the per-type concurrency lock is contended, the re-queue delay grows exponentially with jitter (starting from [`type_busy_retry_seconds`](#perflocale-jobs-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`](#perflocale-jobs-lock-busy-max-seconds), which caps the per-_job_ lock backoff.

```php
add_filter( 'perflocale/jobs/type_busy_max_seconds', static fn(): int => 1800 ); // 30 min cap
```

**Parameters:** `int $seconds` (default 600).  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/lock_busy_max_retries`

Maximum number of times a worker will re-queue itself when the **per-job** concurrency lock is held by another worker. After this many attempts the job is marked failed with a diagnostic message; the operator can manually retry from the Jobs admin page. Distinct from the type-busy retry above — this one trips when the same `job_id` is being processed by a sibling worker (typically a leaked lock row from a crashed worker that never released). Default 20.

```
// Tighter cap for tenants where a wedged lock should escalate to ops faster.
add_filter( 'perflocale/jobs/lock_busy_max_retries', static fn(): int => 5 );
```

**Parameters:** `int $max`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/lock_busy_max_seconds`

Upper bound on the delay between lock-busy retries (the delay grows exponentially with jitter; this caps it). Default 600s (10 min).

```php
add_filter( 'perflocale/jobs/lock_busy_max_seconds', static fn(): int => 1800 ); // 30 min cap
```

**Parameters:** `int $seconds`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/runner`

Globally override the runner instance. For tests, custom deployments, or third-party schedulers (Sidekiq, SQS, etc.). Return a `JobRunnerInterface` instance to bypass the engine setting and Action Scheduler detection.

```php
add_filter( 'perflocale/jobs/runner', static function ( $default ) {
	return new MyCustomQueueRunner();
}, 99 );
```

**Parameters:** `JobRunnerInterface|null $override`.  
**File:** `src/Background/JobRunnerFactory.php`

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

#### `perflocale/po/max_bytes`

Maximum size of a `.po` file accepted by the admin PO importer (_PerfLocale › Strings › Import_). Larger uploads are rejected before parsing, because WordPress core's `\PO` parser accumulates every entry in memory. Gates the admin-UI upload only — the WP-CLI `po-import` path is not size-capped. Default 50 MB.

**Parameters:** `int $bytes`.  
**File:** `src/Admin/AdminController.php`

#### `perflocale/jobs/should_dispatch`

Veto a job dispatch before it runs — the kill switch the per-type capability check can't express on its own. Returning `false` blocks the dispatch entirely (sync or async); the caller receives `['mode' => 'denied', 'error' => ...]`. Returning a **string** vetoes with that string as the human-readable error message. Returning `true` (the default) lets the dispatch proceed unchanged. Distinct from [`perflocale/jobs/threshold/<type>`](#perflocale-jobs-threshold), which only decides sync-vs-async — `should_dispatch` is yes/no.

```php
// Block all jobs between Friday 17:00 UTC and Monday 09:00 UTC (deploy freeze).
add_filter( 'perflocale/jobs/should_dispatch', static function ( $proceed, \PerfLocale\Background\AbstractJob $job, array $args ) {
	$dow  = (int) gmdate( 'w' );
	$hour = (int) gmdate( 'H' );
	$frozen = ( $dow === 5 && $hour >= 17 ) || $dow === 6 || ( $dow === 0 ) || ( $dow === 1 && $hour < 9 );
	return $frozen ? 'Deploy freeze in effect — queued operations resume Monday 09:00 UTC.' : $proceed;
}, 10, 3 );

// Per-tenant quota: cap bulk MT dispatches at 1000 source x target pairs per day.
add_filter( 'perflocale/jobs/should_dispatch', static function ( $proceed, $job, $args ) {
	if ( $job->get_type() !== 'bulk_translate' ) { return $proceed; }
	$today_count = (int) get_transient( 'perflocale_bulk_mt_quota_' . gmdate( 'Y-m-d' ) );
	$cost        = count( $args['source_ids'] ?? [] ) * count( $args['target_lang_ids'] ?? [] );
	if ( $today_count + $cost > 1000 ) {
		return 'Daily bulk-MT quota exhausted (1000 pairs/day).';
	}
	set_transient( 'perflocale_bulk_mt_quota_' . gmdate( 'Y-m-d' ), $today_count + $cost, DAY_IN_SECONDS );
	return $proceed;
}, 10, 3 );
```

**Parameters:**

-   `bool $proceed` — default `true`.
-   `AbstractJob $job` — the job instance about to dispatch (call `$job->get_type()` to branch by type).
-   `array $args` — the dispatch args (shape varies per job type).

**Returns:** `bool|string`. Return `false` to deny with the default error, or a non-empty string to deny with a custom error.  
**File:** `src/Background/Dispatcher.php`

### Reliability & Circuit Breakers

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

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

#### `perflocale/breaker/disabled`

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

```
// Disable all breakers (debugging only — re-enable as soon as possible).
add_filter( 'perflocale/breaker/disabled', '__return_true' );
```

**Parameters:** `bool $disabled` — Default false.  
**File:** `src/Concurrency/Breaker.php`

#### `perflocale/breaker/threshold`

Number of failures within the rolling window that trips a breaker into OPEN. Global default 5. Per-key variant `perflocale/breaker/threshold/<key>` overrides for a specific breaker (e.g. `perflocale/breaker/threshold/mt_deepl`).

```
// Tighten threshold for the MT provider — trip after 2 failures.
add_filter( 'perflocale/breaker/threshold/mt_deepl', static fn(): int => 2 );

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

**Parameters:** `int $threshold` — Default 5. Floor 1.  
**File:** `src/Concurrency/Breaker.php`

#### `perflocale/breaker/window_seconds`

Length of the sliding window (seconds) within which the failure counter accumulates. A failure older than this resets the counter. Global default 300s (5 minutes). Per-key variant `perflocale/breaker/window_seconds/<key>`.

```
// Short window for webhook deliveries — only count failures in the last minute.
add_filter( 'perflocale/breaker/window_seconds/webhook_my-uuid', static fn(): int => 60 );
```

**Parameters:** `int $seconds` — Default 300. Floor 1.  
**File:** `src/Concurrency/Breaker.php`

#### `perflocale/breaker/cooldown_seconds`

How long the breaker stays in OPEN state before allowing a single probe call (HALF\_OPEN). If the probe succeeds, the breaker closes; if it fails, it re-opens for another cooldown cycle. Global default 300s. Per-key variant `perflocale/breaker/cooldown_seconds/<key>`.

```
// Long cooldown for an unreliable third-party FX API — try once per hour.
add_filter( 'perflocale/breaker/cooldown_seconds/fx_sync', static fn(): int => HOUR_IN_SECONDS );

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

**Parameters:** `int $seconds` — Default 300. Floor 1.  
**File:** `src/Concurrency/Breaker.php`

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

#### `perflocale/breaker/probe_lease_seconds`

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

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

**Parameters:** `int $seconds`, `string $key`.  
**File:** `src/Concurrency/Breaker.php`

#### `BreakerOpenException` (catchable)

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

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

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

### Export & Import

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

#### `perflocale/export/sections`

Register one or more top-level sections that your addon wants to add to the exported JSON envelope (siblings of `data`, `settings`, `roles`). Each entry in the returned array is written as `"<section_name>": <json>` at the envelope's top level. Reserved core keys (`perflocale_export`, `version`, `format_version`, `exported_at`, `site_url`, `sections`, `settings`, `addon_settings`, `disabled_addons`, `roles`, `data`) cannot be overwritten and are silently dropped if a callback tries.

```php
// Acme Reviews addon: ship its rows in every export.
add_filter( 'perflocale/export/sections', static function ( array $sections, array $context ): array {
	global $wpdb;
	$rows = $wpdb->get_results(
		"SELECT id, post_id, language_id, body FROM {$wpdb->prefix}perflocale_addon_acme_reviews",
		ARRAY_A
	);
	$sections['acme_reviews'] = [
		'schema_version' => 2,
		'rows'           => $rows ?: [],
	];
	return $sections;
}, 10, 2 );
```

**Parameters:**

-   `array $sections` — existing sections (empty by default). Keyed by section name; each value is JSON-serialisable.
-   `array $context` — `{ requested: string[], format_version: int }`. `requested` is the user-chosen section list (skip your section if it's not requested); `format_version` is the envelope schema version (always `1` in v1.0).

**Returns:** `array<string, mixed>`.  
**File:** `src/Admin/DataExporter.php`

### Machine Translation (bulk)

#### `perflocale/mt/bulk/before_translate`

Short-circuit a single _(source post, target language)_ row inside [the bulk-translate job](https://perflocale.com/docs/background-jobs/) 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](#perflocale-mt-bulk-after-translate) still fires, with `short_circuited => true`).
-   Return `null` — default flow runs.

```php
// Skip MT on posts tagged "do-not-translate".
add_filter( 'perflocale/mt/bulk/before_translate', static function ( $pre, int $source_id, string $target_slug, array $ctx ) {
	if ( has_term( 'do-not-translate', 'post_tag', $source_id ) ) {
		return 'skip'; // counted as skipped, no provider call made
	}
	return $pre;
}, 10, 4 );

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

**Parameters:**

-   `mixed $pre` — `null` by default.
-   `int $source_id` — source post ID.
-   `string $target_slug` — target language slug (e.g. `de`, `ar-EG`).
-   `array $context` — `{ source_id, target_slug, target_id, provider, processed, total }`. `provider` is the configured MT provider key (`deepl` / `google` / etc.). `processed` / `total` let you log progress.

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

#### `perflocale/mt/bulk_string_max_per_dispatch`

Hard ceiling on how many **strings** a single _Bulk-Translate Strings_ dispatch (Strings admin page → bulk MT toolbar) will touch. Default `5000`. The cap applies to all three selection modes — an explicit ID list, a filtered set, or _all strings_ — so a misclick on a 100k-row table can’t turn into one runaway provider bill. Raise it only if you genuinely intend to translate more strings in one run, and have the provider quota/budget for it.

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

```
// Roomy host with budget: allow up to 20,000 strings per bulk dispatch.
add_filter( 'perflocale/mt/bulk_string_max_per_dispatch', fn() => 20000 );
```

**Parameters:**

-   `int $cap` — the ceiling, `5000` by default. The return value is cast to `int`.

**Returns:** int — the maximum number of strings a single dispatch may translate.  
**File:** `src/Background/Jobs/BulkStringTranslateJob.php`

## Actions

### Caching

#### `perflocale/cache/purge_urls`

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

This exists because a translated page is a separate post with its own language-prefixed URL, and a full-page cache generally does not know those URLs exist. Worse, a cache that purges by permalink computes it during an admin or CLI request, where PerfLocale deliberately does _not_ add the language prefix — so the URL it purges is not the URL it stored, and a page that has just been made private can stay publicly readable in the cache until it expires.

PerfLocale resolves the real URLs and publishes them. It deliberately calls **no** cache plugin itself: several per-post purge APIs go on to purge a CDN or Varnish over the network, and a translation plugin should not silently add outbound HTTP to somebody else’s save. With no listener this hook costs nothing.

```php
add_action( 'perflocale/cache/purge_urls', function ( array $urls, int $post_id ): void {
	foreach ( $urls as $url ) {
		my_page_cache_purge( $url );
	}
}, 10, 2 );
```

Purge only the URLs you are given. A full cache flush on every visibility change is exactly the behaviour this hook exists to avoid.

**Parameters:** `array $urls` - Absolute front-end URLs, language prefixes included.  
`int $post_id` - The post whose visibility changed.  
**File:** `src/Cache/CacheInvalidator.php`

### Plugin Lifecycle

#### `perflocale/activated`

Fires after the plugin is activated.

**Parameters:** `string $version` - Plugin version.  
**File:** `src/Activator.php`

#### `perflocale/activation/chunk_size`

Sites fetched per iteration during multisite network activation. PerfLocale activates each subsite in chunks so a network with tens of thousands of sites doesn't load every row at once. Lower this on memory-constrained PHP workers; raise it if you've benchmarked your activation hook and want fewer round-trips.

```php
add_filter( 'perflocale/activation/chunk_size', fn() => 25 );
```

**Parameters:** `int $chunk` - Default `100`. Floored at 1.  
**File:** `perflocale.php`

#### `perflocale/deactivated`

Fires after the plugin is deactivated.

**File:** `src/Deactivator.php`

#### `perflocale/loaded`

Fires after PerfLocale is fully bootstrapped and all services are registered.

```php
add_action( 'perflocale/loaded', function ( \PerfLocale\Plugin $plugin ): void {
	// Safe to use all PerfLocale services here.
	$router = $plugin->get( 'router' );
} );
```

**Parameters:** `Plugin $plugin` - The plugin container instance.  
**File:** `src/Bootstrap.php`

#### `perflocale/upgraded`

Fires after a database schema migration completes.

**Parameters:**

-   `string $old_version` - Previous schema version.
-   `string $new_version` - New schema version.

**Args:** 2  
**File:** `src/Database/Migrator.php`

#### `perflocale/updated`

Fires after the plugin code version changes (e.g., updated from 1.0.0 to 1.1.0). Unlike `perflocale/upgraded` which fires on DB schema changes, this fires on any code version bump. Useful for version-specific non-DB tasks like migrating settings or regenerating files.

```php
add_action( 'perflocale/updated', function ( string $old_version, string $new_version ): void {
	if ( version_compare( $old_version, '1.1.0', '<' ) ) {
		// One-time task for 1.1.0 update.
	}
}, 10, 2 );
```

**Parameters:**

-   `string $old_version` - Previous plugin version.
-   `string $new_version` - Current plugin version.

**Args:** 2  
**File:** `src/Database/Migrator.php`

#### `perflocale/strings/after_scan`

Fires after the "Scan for Strings" action completes. Addons hook here to register non-gettext translatable strings (e.g., WooCommerce email subjects, attribute labels).

```php
add_action( 'perflocale/strings/after_scan', function (): void {
	// Register custom non-gettext strings here.
} );
```

**Args:** 0  
**File:** `src/Background/Jobs/StringScanJob.php`

### Language Events

#### `perflocale/language/detected`

Fires after the current language is detected from the request.

```php
add_action( 'perflocale/language/detected', function ( string $slug, string $method ): void {
	// $method is 'url', 'cookie', 'browser', 'edge_hint', or 'default'.
	if ( $method === 'browser' ) {
		// First-time visitor, language detected from browser.
	}
}, 10, 2 );
```

**Parameters:**

-   `string $slug` - Detected language slug.
-   `string $method` - Detection method used.

**File:** `src/Router/LanguageRouter.php`

#### `perflocale/language/overridden`

Fires when the current language is temporarily swapped for an internal rendering window — for example, rendering an order email in the customer’s locale. Only fires on an actual change (a same-language no-op does not fire it).

**Parameters:** none.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/language/added`

Fires after a new language is added.

**Parameters:** `object $language` - The language object.  
**File:** `src/Database/Repository/LanguageRepository.php`

#### `perflocale/language/updated`

Fires after a language is updated.

**Parameters:**

-   `object $language` - Updated language object.
-   `array $old` - Previous language data.

**File:** `src/Database/Repository/LanguageRepository.php`

#### `perflocale/language/deleted`

Fires after a language is deleted.

**Parameters:**

-   `int $id` - Language ID.
-   `string $slug` - Language slug.

**File:** `src/Database/Repository/LanguageRepository.php`

#### `perflocale/default_language/changed`

Fires after the default language is changed.

**Parameters:**

-   `object $new_default` - New default language.
-   `object $old_default` - Previous default language.

**File:** `src/Database/Repository/LanguageRepository.php`

#### `perflocale/language/slug_renamed`

Fires after a language slug is renamed (e.g. `en` → `en-us`). Old URLs are 301-redirected to the new slug; this hook lets integrations sync downstream caches, search indices, or external systems that referenced the old slug.

**Parameters:**

-   `int $id` - Language row ID.
-   `string $old_slug` - Previous slug.
-   `string $new_slug` - Renamed slug.

**File:** `src/Database/Repository/LanguageRepository.php`

#### `perflocale/languages/reordered`

Fires after the Languages-list drag-and-drop reorder UI persists a new `sort_order` for one or more rows.

**Parameters:**

-   `array $ordered_ids` - Language IDs in their new visual order.
-   `int $offset` - Starting position (0-based) — sort\_order values run `$offset + 1 … $offset + count($ordered_ids)`. Lets the caller renumber a paginated slice without disturbing rows on other pages.

**File:** `src/Database/Repository/LanguageRepository.php`

### Translation Events

#### `perflocale/translation/created`

Fires after a translation post or term is created.

```php
add_action( 'perflocale/translation/created', function ( int $new_id, string $type, string $target_slug, int $source_id ): void {
	if ( $type === 'post' ) {
		// Correlate source → translation, e.g. send to CRM / analytics.
		my_crm_record_translation_pair( $source_id, $new_id, $target_slug );
	}
}, 10, 4 );
```

**Parameters:**

-   `int $new_id` - New post/term ID.
-   `string $type` - Object type (`'post'` or `'term'`).
-   `string $target_slug` - Target language slug.
-   `int $source_id` - Source post/term ID the translation was created from. Useful for correlating analytics, CRM entries, or webhook payloads without a secondary translation-group lookup.

**Args:** 4  
**File:** `src/Translation/PostTranslationManager.php`, `src/Translation/TermTranslationManager.php`

#### `perflocale/translation/meta_copy_failed`

Fires when one or more of the post-meta / featured-image / taxonomy-term copy steps throws while creating a translation. The translation post and its group link are **still created** — a linked translation with missing meta is recoverable via re-sync, whereas rolling back would lose the translator’s work — so this is a notification signal, not an error you need to recover from. The same failures are also written to the `_perflocale_meta_copy_errors` post meta (surfaced as a per-post admin notice); this hook lets integrators forward the signal to Slack, email, or an error tracker.

```php
add_action( 'perflocale/translation/meta_copy_failed', function ( int $new_post_id, int $source_id, string $target_slug, array $errors ): void {
	foreach ( $errors as $err ) {
		// $err = [ 'step' => 'post_meta'|'featured_image'|'taxonomy_terms', 'message' => string ]
		error_log( sprintf(
			'PerfLocale copy step "%s" failed for translation %d (source %d, %s): %s',
			$err['step'], $new_post_id, $source_id, $target_slug, $err['message']
		) );
	}
}, 10, 4 );
```

**Parameters:**

-   `int $new_post_id` — the newly-created translation post ID.
-   `int $source_id` — the source post the translation was created from.
-   `string $target_slug` — target language slug.
-   `array $errors` — one entry per failed step: `{ step: 'post_meta'|'featured_image'|'taxonomy_terms', message: string }`.

**Args:** 4  
**File:** `src/Translation/PostTranslationManager.php`

#### `perflocale/slug/write_failed`

Fires when a translated slug could not be written. This is not fatal — the object keeps its untranslated slug and its URL still resolves — but without this hook the failure is invisible: `set_slug()` returns `false` and most callers have nothing useful to do with it. Use it to tally failures during a bulk import, or to alert when a table is broken. A matching line is also written to the error log, but only when `WP_DEBUG` is on, so that a broken table during a 10,000-term import cannot flood a production log.

```php
add_action( 'perflocale/slug/write_failed', function ( string $reason, array $context ): void {
	if ( $reason === 'db_error' ) {
		my_alerting_channel( sprintf(
			'PerfLocale could not write a slug for %s #%d: %s',
			$context['object_type'], $context['object_id'], $context['db_error']
		) );
	}
}, 10, 2 );
```

**Parameters:**

-   `string $reason` — one of:
    -   `'empty_slug'` — the slug sanitised down to nothing (for example a title made entirely of punctuation or of characters `sanitize_title()` strips).
    -   `'duplicate_exhausted'` — the slug and all 99 `-2`…`-100` suffixes are already taken in that language. Realistically only reachable on a pathological data set.
    -   `'db_error'` — the write itself failed (missing table, permissions, disk).
-   `array $context` — `object_type`, `object_subtype`, `object_id`, `language_id`, `slug` (the slug that was attempted) and, for `db_error`, `db_error` with the database message.

**Args:** 2  
**File:** `src/Database/Repository/SlugTranslationRepository.php`

#### `perflocale/translation/linked`

Fires when an object is linked to a translation group.

**Parameters:**

-   `int $group_id` - Translation group ID.
-   `int $object_id` - Object ID.
-   `int $language_id` - Language ID.

**File:** `src/Database/Repository/TranslationGroupRepository.php`

#### `perflocale/translation/status_changed`

Fires when a translation's status changes (e.g., draft to published).

**Parameters:**

-   `int $object_id` - Object ID.
-   `string $status` - New status.
-   `int $language_id` - Language ID.

**File:** `src/Database/Repository/TranslationGroupRepository.php`

#### `perflocale/content/changed`

Fires when source content changes, marking translations as potentially outdated.

**Parameters:**

-   `int $object_id` - Object ID.
-   `string $type` - Object type value.
-   `int $group_id` - Translation group ID.

**File:** `src/Translation/ContentChangeDetector.php`

### Settings

#### `perflocale/settings/updated`

Fires after plugin settings are saved.

**Parameters:**

-   `array $merged` - Merged (new) settings array.
-   `array $current` - Previous settings array.

**File:** `src/Settings.php`

### Translation Status

#### `perflocale/translations/bulk_marked_needs_update`

Fires after the Translations-page bulk “Mark as Needs Update” action flips `translation_links.status` to `needs_update` for one or more `(source_post, target_lang)` pairs. Use this to dispatch translator notifications or push staleness flags to an external system.

**Parameters:**

-   `array $source_ids` - Source post IDs.
-   `array $target_lang_ids` - Target language IDs (single ID or all active languages).
-   `int $count` — Number of 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.

**File:** `src/Admin/AdminController.php`

### Machine Translation

#### `perflocale/machine_translation/before`

Fires before machine translation starts for a post.

**Parameters:**

-   `int $post_id` - Post ID.
-   `string $provider_id` - Provider identifier.

**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/machine_translation/after`

Fires after machine translation completes successfully.

**Parameters:**

-   `int $post_id` - Source post ID.
-   `string $provider_id` - Provider identifier.
-   `array $result` - Translation result (`title`, `content`, `excerpt`, `post_id`; plus `meta` counts when meta translation ran).
-   `string $target_lang` - Target language slug.
-   `string $source_lang` - Source language slug.
-   `WP_Post $post` - Source post object.

**File:** `src/MachineTranslation/TranslationService.php`

#### `perflocale/mt/meta_translate_failed`

Fires when one or more meta values could not be machine-translated. The post translation itself is unaffected — failed keys keep their source value and the reason is stored in the `_perflocale_meta_mt_errors` post meta on the translation.

**Parameters:**

-   `int $source_id` - Source post ID.
-   `int $translation_id` - Translation post ID.
-   `string[] $keys` - Meta keys involved.
-   `string $message` - Human-readable summary.

**File:** `src/MachineTranslation/MetaTranslator.php`

#### `perflocale/machine_translation/failed`

Fires when machine translation fails with an exception.

**Parameters:**

-   `int $post_id` - Post ID.
-   `string $provider_id` - Provider identifier.
-   `Exception $exception` - The exception.

**File:** `src/MachineTranslation/TranslationService.php`

### Cache

#### `perflocale/cache/flush_all`

Fires after the entire plugin cache is flushed.

**File:** `src/Cache/CacheManager.php`

#### `perflocale/cache/flush_object`

Fires after cache is flushed for a specific object.

**Parameters:**

-   `int $object_id` - Object ID.
-   `string $object_type` - Object type.

**File:** `src/Cache/CacheManager.php`

#### `perflocale/cache/flush_translations`

Fires after the per-object translation-link caches (L1 static memo, L2 group generation, and L3 transient envelopes) are flushed — e.g. on a wholesale change such as a language deletion that removes link rows without enumerating each affected sibling object. Hook it to invalidate any external store keyed off translation groupings.

**Parameters:** none  
**File:** `src/Cache/CacheManager.php`

### Background Jobs

Lifecycle actions for the background-processing system. See the [Background Jobs](https://perflocale.com/docs/background-jobs/) doc for the full feature reference.

#### `perflocale/jobs/enqueued`

Fires after a job has been successfully enqueued for async execution.

```php
add_action( 'perflocale/jobs/enqueued', static function ( string $job_id, string $type, string $engine, array $args ): void {
	error_log( "PerfLocale enqueued $type job $job_id on $engine" );
}, 10, 4 );
```

**Parameters:**

-   `string $job_id` — UUID v4 of the new job.
-   `string $type` — job type slug (e.g. `string_scan`, `data_import`).
-   `string $engine` — runner engine: `action_scheduler` or `wp_cron`.
-   `array $args` — the dispatch args.

**File:** `src/Background/Dispatcher.php`

#### `perflocale/jobs/completed`

Fires after a worker finishes a job successfully.

```php
add_action( 'perflocale/jobs/completed', static function ( string $job_id, string $type, array $result ): void {
	// Push to your metrics pipeline...
}, 10, 3 );
```

**Parameters:** `string $job_id`, `string $type`, `array $result` (the worker's untruncated return value; the copy stored on the job row is replaced with a small truncation stub when it serialises over MAX\_RESULT\_BYTES = 64 KB).  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/failed`

Fires when a worker throws an exception and the retry-with-backoff is about to be scheduled (or skipped because the attempt cap has been hit). Receives the FULL untruncated throwable, useful for monitoring — the version persisted to the job row is path-redacted and truncated.

```php
add_action( 'perflocale/jobs/failed', static function ( string $job_id, string $type, \Throwable $e ): void {
	sentry_capture_exception( $e, [
		'tags' => [ 'perflocale_job_id' => $job_id, 'perflocale_job_type' => $type ],
	] );
}, 10, 3 );
```

**Parameters:** `string $job_id`, `string $type`, `\Throwable $e`.  
**File:** `src/Background/WorkerRegistry.php`

#### `perflocale/jobs/canceled`

Fires when a long-running worker cooperatively aborts itself in response to an operator cancel mid-flight. Distinct from `perflocale/jobs/failed` because it isn't an error — useful for distinguishing operator-canceled from worker-errored in dashboards.

**Parameters:** `string $job_id`, `string $type`.  
**File:** `src/Background/WorkerRegistry.php`

### Export & Import

Lifecycle hooks for the data-export / data-import flow. Pair with the [Export & Import filters](#filter-export-import) to round-trip addon-shipped data through `wp perflocale export/import`.

#### `perflocale/export/written`

Fires inside `DataExportJob::execute()` after the export file has been successfully written to disk, BEFORE the job result is stored on the JobState row. The file is still readable at `$path` when this action fires — it will be served (and deleted) later by the download endpoint when the operator clicks _Download_ on PerfLocale → Jobs.

Most common use: offsite-backup hooks (copy the export to S3 / Dropbox / a remote shell) and audit-log entries. Note this action fires only from the `data_export` background job. An export that stays below the async threshold is streamed straight to the browser by `DataExporter::download()` and never touches disk, so it does **not** fire this hook — and neither does `wp perflocale export`, which calls `DataExporter::write_to_file()` directly.

```php
// Mirror every successful site export to S3.
add_action( 'perflocale/export/written', static function ( string $path, int $bytes, array $sections ): void {
	if ( $bytes === 0 ) { return; }
	my_addon_s3_upload(
		$path,
		's3://acme-backups/perflocale/' . gmdate( 'Y/m/d/' ) . basename( $path )
	);
}, 10, 3 );
```

**Parameters:**

-   `string $path` — absolute path to the written export file (always inside `uploads/perflocale/exports/`).
-   `int $bytes` — size of the written file.
-   `array<int,string> $sections` — the section keys that were exported (`settings`, `roles`, `languages`, `translations`, `strings`, `slugs`, …).

**File:** `src/Background/Jobs/DataExportJob.php`

#### `perflocale/export/download/before_serve`

Fires after every gate (nonce, capability, job-status, realpath) has passed and the export file is about to be streamed to the operator's browser, BEFORE any HTTP header is sent. Use for compliance / audit-log entries (_"user X downloaded export Y at Z"_) and monitoring-pipeline events. **Do not `echo`** from callbacks here — output would land in the response body before the JSON download.

```php
add_action( 'perflocale/export/download/before_serve', static function ( string $job_id, string $path, array $job ): void {
	my_audit_log( [
		'event'   => 'export_download',
		'user_id' => get_current_user_id(),
		'job_id'  => $job_id,
		'size'    => filesize( $path ),
	] );
}, 10, 3 );
```

**Parameters:** `string $job_id`, `string $real_path`, `array $job` (the JobState row).  
**File:** `src/Admin/AdminController.php`

#### `perflocale/export/download/after_serve`

Fires AFTER the export file has been streamed to the browser and BEFORE the single-use deletion takes effect. The file is still on disk at `$real_path` when this action fires — this is the **last chance** to act on it. Useful for chaining follow-up work (queue an integrity-check job that re-reads the file, kick off offsite-backup, push a webhook). Don't `echo` — the HTTP body is already being sent.

```
// Queue an integrity-check job on the freshly-served export.
add_action( 'perflocale/export/download/after_serve', static function ( string $job_id, string $path, array $job, int $size ): void {
	\PerfLocale\Background\BackgroundEvents::enqueue(
		'my_addon_verify_export_hash',
		[ $path, hash_file( 'sha256', $path ) ],
		0
	);
}, 10, 4 );
```

**Parameters:** `string $job_id`, `string $real_path`, `array $job`, `int $size` (bytes served, 0 when `filesize()` failed).  
**File:** `src/Admin/AdminController.php`

#### `perflocale/import/section/<name>`

Fires once for every top-level key in the import envelope that isn't a core section (`settings`, `roles`, `data`, etc.). The action name is dynamic — `<name>` is the section key your addon used in the [matching export filter](#perflocale-export-sections). Use the same key on both sides so round-tripping works automatically.

```php
// Acme Reviews addon: restore its own rows during an import.
add_action( 'perflocale/import/section/acme_reviews', static function ( $section_data, array $ctx ): void {
	global $wpdb;
	$rows = is_array( $section_data ) ? ( $section_data['rows'] ?? [] ) : [];

	if ( $ctx['replace'] ) {
		$wpdb->query( "TRUNCATE TABLE {$wpdb->prefix}perflocale_addon_acme_reviews" );
	}

	foreach ( $rows as $r ) {
		$wpdb->insert( "{$wpdb->prefix}perflocale_addon_acme_reviews", $r );
	}
}, 10, 2 );
```

**Parameters:**

-   `mixed $section_data` — the decoded JSON payload your addon wrote to this section. Same shape the export filter returned.
-   `array $context` — `{ replace: bool, format_version: int, file_path: string }`. `replace` mirrors the import's replace-mode flag; truncate your tables when it's `true`. `file_path` is the path of the source envelope.

**File:** `src/Admin/DataImporter.php`

#### `perflocale/import/completed`

Fires after `DataImporter::import()` has finished restoring rows and flushing caches, with the final result stats. Fires for BOTH sync and async (`data_import` job) code paths. Use for cache invalidation, audit logging, or Slack notifications.

```php
add_action( 'perflocale/import/completed', static function ( array $result, string $file_path, bool $replace ): void {
	if ( ! empty( $result['errors'] ) ) { return; }
	wp_remote_post( 'https://hooks.slack.com/...', [
		'body' => wp_json_encode( [
			'text' => sprintf(
				'PerfLocale import done: %d rows imported, %d skipped (replace=%s).',
				$result['imported'],
				$result['skipped'],
				$replace ? 'yes' : 'no'
			),
		] ),
	] );
}, 10, 3 );
```

**Parameters:** `array $result` (`{ imported: int, skipped: int, errors: string[] }`), `string $file_path`, `bool $replace`.  
**File:** `src/Admin/DataImporter.php`

### Machine Translation (bulk)

#### `perflocale/mt/bulk/after_translate`

Fires after every _(source post, target language)_ row inside [the bulk-translate job](https://perflocale.com/docs/background-jobs/), whether the translation succeeded, failed, or was short-circuited by [`before_translate`](#perflocale-mt-bulk-before-translate). Use for per-row observability: metrics, monitoring pipelines, audit events.

```
// Per-row APM metric for bulk MT.
add_action( 'perflocale/mt/bulk/after_translate', static function ( int $source_id, string $target_slug, array $result, array $ctx ): void {
	my_apm_increment( 'perflocale.mt.bulk.row', [
		'provider'        => $ctx['provider'] ?: 'unknown',
		'target'          => $target_slug,
		'outcome'         => ! empty( $result['post_id'] ) ? 'created' : ( $ctx['short_circuited'] ? 'skipped_filter' : 'failed' ),
		'short_circuited' => $ctx['short_circuited'] ? '1' : '0',
	] );
}, 10, 4 );
```

**Parameters:**

-   `int $source_id` — source post ID.
-   `string $target_slug` — target language slug.
-   `array $result` — TranslationService result (`['post_id' => int]` on success; empty array on failure).
-   `array $context` — same shape as the `before_translate` context plus two extra keys:
    -   `short_circuited: bool` — true if the row was handled by a `before_translate` filter callback.
    -   `error: string` — the exception message or fallback error text; empty string on success.

**File:** `src/Background/Jobs/BulkTranslateJob.php`

### Addons

#### `perflocale/addons/cache/flush`

Manually-triggered action: `do_action( 'perflocale/addons/cache/flush' )` forces PerfLocale to invalidate its bootable-addons transient (the memoised list of which addons pass their compatibility check). PerfLocale already flushes this automatically on plugin activation and deactivation, theme switch, and plugin/theme upgrade; call this action from a misbehaving environment where one of those lifecycle events didn’t fire.

```
// Force a rebuild of the bootable-addons cache.
do_action( 'perflocale/addons/cache/flush' );
```

**Parameters:** none  
**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addon/activated`

Fires when an addon is activated (booted).

**Parameters:** `string $addon_id` - Addon identifier.  
**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addons/loaded`

Fires after all addons have been loaded.

**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addon/seeded`

Fires once, immediately after the registry writes an addon's declared default settings on its first successful boot (the auto-seed pass — see [Developer Toolkit → Settings](https://perflocale.com/docs/addon-system/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)`.

```php
add_action( 'perflocale/addon/seeded', function ( string $addon_id, array $defaults ): void {
	if ( $addon_id !== 'my-addon' ) {
		return;
	}
	// Schedule the welcome email once.
	wp_schedule_single_event( time() + 60, 'myaddon/welcome_email' );
}, 10, 2 );
```

**Parameters:**

-   `string $addon_id` — Addon's `get_id()` value.
-   `array $defaults` — The defaults that were seeded.

**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addon/settings/before_save`

Fires inside the storage lock, immediately BEFORE the `perflocale_addon_settings` option commits a new entry for an addon. Use for pre-save auditing, validation logging, or cache invalidation that needs to happen while both old and new values are still visible.

**Reentrancy:** the write lock is non-reentrant. Calling `AddonSettings::set()` / `set_addon()` / `forget()` from a listener will time out at 10 seconds and return `false`. For cross-addon write reactions, hook `perflocale/addon/settings/after_save` from a `wp_schedule_single_event` deferred callback so your write happens outside the lock window.

```php
add_action( 'perflocale/addon/settings/before_save', function ( string $addon_id, array $new, array $old ): void {
	error_log( sprintf(
		'addon=%s changed=%s',
		$addon_id,
		wp_json_encode( array_diff_assoc( $new, $old ) )
	) );
}, 10, 3 );
```

**Parameters:**

-   `string $addon_id` — Addon being saved.
-   `array $new_entry` — The values about to commit.
-   `array $old_entry` — The pre-save values (empty array if no entry existed).

**File:** `src/Addon/AddonSettings.php`

#### `perflocale/addon/settings/after_save`

Fires inside the storage lock, immediately AFTER the option commits. Same reentrancy caveat as `before_save`. This is the right hook for "addon A reacts to addon B's settings change" via a deferred dispatch:

```php
add_action( 'perflocale/addon/settings/after_save', function ( string $addon_id, array $new, array $old ): void {
	if ( $addon_id !== 'woocommerce' ) {
		return;
	}
	// Schedule a cache rebuild OUTSIDE the lock window.
	wp_schedule_single_event( time() + 5, 'myaddon/rebuild_currency_cache' );
}, 10, 3 );
```

Neither hook fires on rejected writes (invalid addon id, over the 16 KiB per-addon size cap, or lock acquisition failure). If you need to observe rejections, wrap the call site and check the bool return.

**Parameters:** identical to `before_save`.  
**File:** `src/Addon/AddonSettings.php`

### WooCommerce

#### `perflocale/woocommerce/exchange_rates_synced`

Fires after exchange rates are successfully fetched and saved.

**Parameters:**

-   `array $rates` - Currency code => exchange rate.
-   `string $base_currency` - WooCommerce base currency code.
-   `string $provider_id` - Provider that was used.

**File:** `src/WooCommerce/ExchangeRateSync.php`

#### `perflocale/woocommerce/inventory_synced`

Fires after inventory fields are synced across language variants.

**Parameters:**

-   `int $product_id` - Source product ID. For the variation-sync paths this is the source _variation_ ID, not the parent product.
-   `array $synced_ids` - IDs of sibling translations that were updated.
-   `array $fields` - Meta keys that were synced.

**File:** `src/WooCommerce/InventorySync.php`

### GeoIP Redirect

#### `perflocale/geo/redirected`

Fires after a GeoIP redirect has been performed.

**Parameters:**

-   `string $language_slug` - The language the visitor was redirected to.
-   `string $country_code` - Detected country code.
-   `string $ip` - Visitor IP address.

**File:** `src/Router/GeoRedirect.php`

### Edge Integration (filters)

#### `perflocale/edge/enabled`

Programmatic override for whether the edge-integration feature is active. When returned `true`, PerfLocale publishes `/wp-json/perflocale/v1/config` and honours the `edge_hint` detection method, regardless of the `edge_integration_enabled` setting value.

```php
add_filter( 'perflocale/edge/enabled', '__return_true' );
```

**Parameters:** `bool $enabled` - Current effective state.  
**File:** `src/Settings.php`

#### `perflocale/edge/hint_header`

Rename the HTTP header used to carry the edge-selected language.

```php
add_filter( 'perflocale/edge/hint_header', fn() => 'X-Vercel-Lang' );
```

**Parameters:** `string $header_name` - Default `X-PerfLocale-Lang`.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/edge/hint_cookie`

Rename the cookie used as fallback for edge-selected language.

```php
add_filter( 'perflocale/edge/hint_cookie', fn() => 'my_lang_cookie' );
```

**Parameters:** `string $cookie_name` - Default `perflocale_edge_lang`.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/edge/accept_hint`

Veto a specific edge-hint per request. Return `false` to reject an otherwise valid hint (e.g. behind a reverse proxy that mis-forwards headers).

```php
add_filter( 'perflocale/edge/accept_hint', function ( bool $accept, string $slug ): bool {
	if ( ! empty( $_SERVER['HTTP_X_INTERNAL_PROBE'] ) ) {
		return false;
	}
	return $accept;
}, 10, 2 );
```

**Parameters:**

-   `bool $accept` - Default `true`.
-   `string $slug` - Candidate language slug from header/cookie.

**File:** `src/Router/LanguageRouter.php`

### Garbage Collection & Retention

Fire-and-observe actions emitted at the end of each GC pass. Use them for monitoring (Datadog, New Relic), audit trails (“N strings GC’d on YYYY-MM-DD”), or chained cleanups. None of the callbacks block or alter the GC behaviour. See the matching tuning filters under [Garbage Collection & Retention](#gc-retention) in the Filters section.

#### `perflocale/strings/gc_complete`

Fires after the daily mark-and-sweep GC deletes stale rows from `perflocale_strings` (and the cascade from `perflocale_string_translations`). Only fires when at least one row was deleted — quiet ticks don’t emit. Use it for observability when shipping a wp.org plugin update, so you can confirm the GC actually fired after deploy.

```php
add_action( 'perflocale/strings/gc_complete', static function ( int $strings_deleted, int $translations_deleted ): void {
	if ( function_exists( 'datadog_metric' ) ) {
		datadog_metric( 'perflocale.gc.strings.deleted', $strings_deleted );
		datadog_metric( 'perflocale.gc.string_translations.deleted', $translations_deleted );
	}
}, 10, 2 );
```

**Parameters:**

-   `int $strings_deleted` — rows deleted from `perflocale_strings`.
-   `int $translations_deleted` — rows deleted from `perflocale_string_translations` via the cascade.

**File:** `src/Database/Repository/StringRepository.php`

#### `perflocale/string_translations/orphans_swept`

Fires after the defensive orphan-sweep in the daily GC deletes `perflocale_string_translations` rows whose parent `strings.id` no longer exists. Only fires when at least one orphan was found — a healthy install should never trigger this hook, so any callback firing is a signal that some code path bypassed the normal cascade.

```
// Alert when the orphan-sweep actually finds anything — suggests a buggy
// cascade somewhere we should investigate.
add_action( 'perflocale/string_translations/orphans_swept', static function ( int $deleted ): void {
	error_log( '[PerfLocale] string_translations orphan-sweep deleted ' . $deleted . ' rows — investigate.' );
} );
```

**Parameters:** `int $deleted` — number of orphan rows deleted.  
**File:** `src/Database/Repository/StringTranslationRepository.php`

### CDN Cache-Tag Headers (filters + action)

#### `perflocale/cache_tags/enabled`

Programmatic override for `Cache-Tag` header emission. Returning `false` kills the feature without touching the setting.

**Parameters:** `bool $enabled` - Current effective state.  
**File:** `src/Settings.php`

#### `perflocale/cache_tags/header_name`

Change the response-header name. Useful for Fastly (`Surrogate-Key`) or custom CDNs.

```php
add_filter( 'perflocale/cache_tags/header_name', fn() => 'Surrogate-Key' );
```

**Parameters:** `string $name` - Default `Cache-Tag`.  
**File:** `src/Frontend/CacheTagEmitter.php`

#### `perflocale/cache_tags/tags`

Modify the list of tags emitted for the current request. Tags are ASCII-only (`[A-Za-z0-9\-_:.]`), truncated per-entry to 128 chars and capped at 32 per response.

```php
add_filter( 'perflocale/cache_tags/tags', function ( array $tags ): array {
	$tags[] = 'theme:' . get_template();
	return $tags;
} );
```

**Parameters:** `array $tags` - Sanitised tag list.  
**File:** `src/Frontend/CacheTagEmitter.php`

#### `perflocale/cache_tags/max_header_length`

Response-header byte budget (default 8000). Tags overflowing the budget are dropped silently.

**Parameters:** `int $max` - Default `8000`.  
**File:** `src/Frontend/CacheTagEmitter.php`

#### `perflocale/cache/flush_all` (action)

Fires after PerfLocale flushes its _entire_ cache layer (L1 static memo, every L2 object-cache group, L3 transients). Callers: the admin “Clear cache” button, `wp perflocale cache flush` and `wp perflocale health-check --fix`, a plugin-version database migration, plugin deactivation, every importer run (data import and the WPML / Polylang / TranslatePress migrations), and the bulk “create taxonomy translations” admin action.

It does **not** fire when a language is added, renamed, updated or reordered — those paths run only the narrower `flush_languages()`, which emits no action at all. Deleting a language additionally runs `flush_slugs()` and `flush_translations()`, which emit [`perflocale/cache/flush_slugs`](#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).

```php
add_action( 'perflocale/cache/flush_all', function (): void {
	my_cdn_purge_zone();
} );
```

**Parameters:** none  
**File:** `src/Cache/CacheManager.php`

#### `perflocale/cache/flush_object` (action)

Fires after PerfLocale invalidates a single object’s cache (post or term). Pair with the [cache\_tags/tags](#perflocale-cache-tags-tags) filter to map the object to your CDN tags and issue a targeted purge.

```php
add_action( 'perflocale/cache/flush_object', function ( int $object_id, string $object_type ): void {
	$tag = $object_type . ':' . $object_id;
	my_cdn_purge_tags( [ $tag ] );
}, 10, 2 );
```

**Parameters:** `int $object_id`, `string $object_type` (`post` or `term`).  
**File:** `src/Cache/CacheManager.php`

#### `perflocale/cache/flush_slugs` (action)

Fires after PerfLocale flushes the translated-slug cache layer — currently invoked on language deletion (the `slug_translations` rows for the removed language are deleted alongside the static L1 memo, the persistent L2 group, the L3 transient envelopes, and the `perflocale_has_any_slugs` autoloaded zero-state flag). Hook this when your CDN or edge cache holds per-slug routing data that needs to be invalidated on the same event — e.g. a custom URL-shortener or a slug-keyed redirect table at the edge. PerfLocale does not issue remote purges itself; that’s left to integrators.

```php
add_action( 'perflocale/cache/flush_slugs', function (): void {
	my_cdn_purge_tags( [ 'perflocale:slugs' ] );
} );
```

**Parameters:** none  
**File:** `src/Cache/CacheManager.php`

#### `perflocale/cache/flush_archive_hreflang` (action)

Fires when the archive hreflang cache is invalidated, after PerfLocale’s own wipe — the seam for object-cache-backed installs that need to issue an additional purge. Fires from two paths: a post whose status / language assignment changed, and a term edit (which also flushes its translation siblings).

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

**Parameters:** `int $object_id` — the post ID or term ID whose save / edit / delete triggered the flush. The hook does not distinguish the two, so key any purge tag on something you can disambiguate yourself.  
**File:** `src/Cache/CacheInvalidator.php`

### SEO Schema Enrichment

#### `perflocale/seo/schema_enrichment_enabled`

Programmatic override for JSON-LD schema enrichment across the six built-in SEO addons (Yoast, AIOSEO, Rank Math, SEOPress, Slim SEO, The SEO Framework). Default is `true`; return `false` to suppress `inLanguage` + `workTranslation` additions.

**Parameters:** `bool $enabled` - Current effective state.  
**File:** `src/Settings.php`

### Rate Limits & Scan Caps

#### `perflocale/mt/rate_limit`

Per-user hourly ceiling on machine-translation requests. All three MT entry points share this budget: `POST /perflocale/v1/machine-translate`, `POST /perflocale/v1/block-translate`, and the `perflocale/translate-post` Ability (which is itself reachable over REST and MCP). Return `0` to disable the _per-user_ cap only — the site-wide cap from [`perflocale/mt/rate_limit_site`](#perflocale-mt-rate-limit-site) still applies and will still return HTTP 429. Throttling stops entirely only when both filters return `0`.

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

**Parameters:** `int $limit` - Default `500` requests per hour.  
**File:** `src/Translation/MtRateLimiter.php`

#### `perflocale/mt/rate_limit_site`

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

```
// Tighten the global ceiling for a shared budget across an editorial team.
add_filter( 'perflocale/mt/rate_limit_site', fn() => 2000 );

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

**Parameters:** `int $limit` - Default `5000` requests per hour, summed across all users. Set to `0` to disable the site cap.  
**File:** `src/Translation/MtRateLimiter.php`

#### `perflocale/import/quality_limits`

Tune the data-quality gate that every import passes BEFORE any database write. The gate rejects invalid UTF-8, null bytes, cardinality bombs, oversized values, and malformed rows — errors name the exact JSON path so the operator can fix the file.

```
// Agency importing a 900-language terminology corpus.
add_filter( 'perflocale/import/quality_limits', fn() => [
	'max_languages'      => 1000,
	'max_rows_per_table' => 2000000,
	'max_value_bytes'    => 4 * 1048576,
] );
```

**Parameters:** `array $limits` - Keys: `max_languages` (default 500), `max_rows_per_table` (default 500,000), `max_value_bytes` (default 1,048,576).  
**File:** `src/Admin/DataImporter.php`

### Environment Gates

#### `perflocale/dispatch/allow_non_production`

Outward-facing dispatches **fail closed on non-production environments**: staging and development clones carry the production webhook URLs in their cloned database, so without the gate a clone silently fires production endpoints. Managed hosts (WP Engine, Kinsta, etc.) set `WP_ENVIRONMENT_TYPE=staging` on clones automatically; WordPress defaults to `production` when unset, so ordinary single-environment sites are unaffected. Return `true` to opt a surface back in.

```php
// QA environment that owns its own webhook endpoints — allow webhooks there.
add_filter( 'perflocale/dispatch/allow_non_production', function ( bool $allow, string $feature ): bool {
	return $feature === 'webhooks';
}, 10, 2 );
```

**Parameters:**

-   `bool $allow` - Default `false`.
-   `string $feature` - Dispatch surface. Currently only `'webhooks'`.
-   `string $environment` - Current `wp_get_environment_type()` value.

**File:** `src/Helper.php`

#### `perflocale/dispatch/blocked` (action)

Fires when the non-production gate blocks a dispatch — the observability hook for loggers or admin notices that surface “webhooks are intentionally muted on this clone.”

```php
add_action( 'perflocale/dispatch/blocked', static function ( string $feature, string $environment ): void {
	error_log( "PerfLocale: {$feature} dispatch muted on {$environment} environment." );
}, 10, 2 );
```

**Parameters:** `string $feature`, `string $environment`.  
**File:** `src/Helper.php`

### Garbage Collection & Retention

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

#### `perflocale/strings/stale_retention_days`

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

```
// Hold on to potentially-stale strings for a full year.
add_filter( 'perflocale/strings/stale_retention_days', fn() => 365 );

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

**Parameters:** `int $days` - Default `90`. Set to `0` to disable the GC.  
**File:** `src/Database/Repository/StringRepository.php`

#### `perflocale/strings/manual_contexts`

Allowlist of `context` values that the strings GC will **never** delete, even when `last_seen_at` exceeds the retention window. Empty by default. Use it for manually-registered strings whose `register_setting_string()` code path may not run regularly — settings labels, addon-registered templates, and the like.

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

**Parameters:** `string[] $contexts` - Default `[]`.  
**File:** `src/Database/Repository/StringRepository.php`

## JavaScript hooks (`wp.hooks`)

Beyond PHP, PerfLocale exposes one stable JavaScript filter via the WordPress [`@wordpress/hooks`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-hooks/) registry. Use it from a custom block plugin to teach the PerfLocale block-toolbar Translate action which attributes hold translatable text on your block type.

### `perflocale.blockToolbar.textAttrs` (filter)

Filter the ordered list of attribute names the block toolbar will look at when extracting text from a block for machine translation. The default chain inspects the block’s registered attribute schema and returns names of `string` / `rich-text` attributes that look translatable (skipping `className`, `anchor`, alignment etc.). Return an extended array to opt your custom block in — cleanest extension surface that doesn’t require a code change in PerfLocale.

```
wp.hooks.addFilter(
	'perflocale.blockToolbar.textAttrs',
	'acme/my-block',
	function ( chain, blockName ) {
		if ( blockName === 'acme/quote-card' ) {
			// Try `quoteHtml` first, then `attribution`, then fall back to defaults.
			return [ 'quoteHtml', 'attribution' ].concat( chain );
		}
		return chain;
	}
);
```

**Parameters:**

-   `string[] chain` - Ordered list of attribute names. The toolbar walks this chain and translates the first attribute that returns a non-empty string.
-   `string blockName` - The block being inspected (e.g. `core/paragraph`, `acme/quote-card`).

**Return:** array. An empty / non-array return value is ignored and the toolbar falls back to `[ 'content' ]`.  
**File:** `assets/js/block-toolbar.js`

## Additional extension points

The following hooks cover more specialised extension points. They are stable and safe to use in production.

### Addons & integrations

#### `perflocale/addons/register`

Fires after built-in addons are registered so third parties can register their own addon instances before boot.

```php
add_action( 'perflocale/addons/register', function ( \PerfLocale\Addon\AddonRegistry $registry ): void {
	$registry->register( new \My\Plugin\MyAddon() );
} );
```

**Parameters:** `\PerfLocale\Addon\AddonRegistry $registry`.  
**File:** `src/Addon/AddonRegistry.php`

#### `perflocale/addons/registry`

Filter the array of known addons as rendered on the Addons admin page.

```php
add_filter( 'perflocale/addons/registry', function ( array $addons ): array {
	unset( $addons['legacy-addon-id'] );
	return $addons;
} );
```

**Parameters:** `array $addons` - Map keyed by addon ID.  
**File:** `src/Admin/Pages/AddonsPage.php`

#### `perflocale/addon/before_migrate`

Fires once per migration step, before the addon's `migrate_to()` runs.

```php
add_action( 'perflocale/addon/before_migrate', function ( $addon, int $stored, int $target ): void {
	error_log( sprintf( 'addon=%s stored=%d -> step=%d', $addon->get_id(), $stored, $target ) );
}, 10, 3 );
```

**Parameters:** `AddonInterface&HasSchema $addon`, `int $stored`, `int $target`.  
**Args:** 3  
**File:** `src/Addon/AddonSchemaManager.php`

#### `perflocale/addon/migrated`

Fires after each successful addon migration step.

```php
add_action( 'perflocale/addon/migrated', function ( $addon, int $version ): void {
	// Warm caches, record telemetry, etc.
}, 10, 2 );
```

**Parameters:** `AddonInterface&HasSchema $addon`, `int $version`.  
**Args:** 2  
**File:** `src/Addon/AddonSchemaManager.php`

#### `perflocale/addon/migration_failed`

Fires when an addon migration step throws. The addon is not quarantined by this filter - observe or re-raise as needed.

```php
add_action( 'perflocale/addon/migration_failed', function ( $addon, int $version, \Throwable $e ): void {
	error_log( sprintf( '[perflocale] %s v%d failed: %s', $addon->get_id(), $version, $e->getMessage() ) );
}, 10, 3 );
```

**Parameters:** `AddonInterface&HasSchema $addon`, `int $version`, `\Throwable $e`.  
**Args:** 3  
**File:** `src/Addon/AddonSchemaManager.php`

#### `perflocale/addon/manifest_written`

Fires after an addon manifest is re-persisted to disk - used by the uninstall pipeline to know what to purge even when the addon is gone.

```php
add_action( 'perflocale/addon/manifest_written', function ( string $addon_id, array $manifest ): void {
	// Audit-log manifest changes.
}, 10, 2 );
```

**Parameters:** `string $addon_id`, `array $manifest`.  
**Args:** 2  
**File:** `src/Addon/AddonManifestWriter.php`

#### `perflocale/addon/before_uninstall`

Fires before an addon's stored data is purged. Receives the computed `PurgePlan` snapshot.

```php
add_action( 'perflocale/addon/before_uninstall', function ( string $addon_id, \PerfLocale\Addon\PurgePlan $plan ): void {
	// Back up rows referenced by $plan before PerfLocale drops them.
}, 10, 2 );
```

**Parameters:** `string $addon_id`, `\PerfLocale\Addon\PurgePlan $plan`.  
**Args:** 2  
**File:** `src/Addon/AddonUninstaller.php`

#### `perflocale/addon/meta_purge_batch`

Fires once per batched `DELETE` during addon uninstall meta cleanup.

```php
add_action( 'perflocale/addon/meta_purge_batch', function ( string $type, int $deleted, int $total ): void {
	// $type is the OBJECT type: 'post' | 'user' | 'term' | 'comment'.
	// $deleted is this batch's row count, $total the running total for $type.
}, 10, 3 );
```

**Parameters:** `string $type`, `int $deleted`, `int $total`.  
**Args:** 3  
**File:** `src/Addon/AddonUninstaller.php`

#### `perflocale/addon/uninstalled`

Fires after an addon's uninstall pipeline completes.

```php
add_action( 'perflocale/addon/uninstalled', function ( string $addon_id, \PerfLocale\Addon\PurgeResult $result ): void {
	// $result reports counts per table / meta type.
}, 10, 2 );
```

**Parameters:** `string $addon_id`, `\PerfLocale\Addon\PurgeResult $result`.  
**Args:** 2  
**File:** `src/Addon/AddonUninstaller.php`

### Addon settings UI

#### `perflocale/settings/addon_subtabs`

Register a Settings subtab belonging to your addon. Return a map of `slug => label`.

```php
add_filter( 'perflocale/settings/addon_subtabs', function ( array $subtabs ): array {
	$subtabs['my-addon'] = __( 'My Addon', 'my-plugin' );
	return $subtabs;
} );
```

**Parameters:** `array $subtabs`.  
**File:** `src/Admin/AdminController.php`

#### `perflocale/settings/render_addon_subtab`

Render the form fields for a registered addon subtab.

```php
add_action( 'perflocale/settings/render_addon_subtab', function ( string $subtab, \PerfLocale\Settings $settings ): void {
	if ( $subtab !== 'my-addon' ) {
		return;
	}
	// echo <tr> form-table rows here.
}, 10, 2 );
```

**Parameters:** `string $subtab`, `\PerfLocale\Settings $settings`.  
**Args:** 2  
**File:** `src/Admin/Pages/SettingsPage.php`

#### `perflocale/settings/addon_subtab_after`

Fires after the addon subtab form-table, right before the submit button - use for secondary actions.

```php
add_action( 'perflocale/settings/addon_subtab_after', function ( string $subtab, \PerfLocale\Settings $settings ): void {
	if ( $subtab === 'my-addon' ) {
		echo '<p class="description">' . esc_html__( 'Need help? See our docs.', 'my-plugin' ) . '</p>';
	}
}, 10, 2 );
```

**Parameters:** `string $subtab`, `\PerfLocale\Settings $settings`.  
**Args:** 2  
**File:** `src/Admin/Pages/SettingsPage.php`

#### `perflocale/settings/extract_addon_values`

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

```php
add_filter( 'perflocale/settings/extract_addon_values', function ( array $values, string $tab ): array {
	if ( $tab !== 'my-addon' ) {
		return $values;
	}
	$values['my_key'] = sanitize_text_field( wp_unslash( $_POST['my_key'] ?? '' ) );
	return $values;
}, 10, 2 );
```

**Parameters:** `array $values`, `string $tab`.  
**Args:** 2  
**File:** `src/Admin/Pages/SettingsPage.php`

### GeoIP

#### `perflocale/geo/visitor_ip`

Override the visitor IP used for GeoIP lookups. Useful for testing on localhost or for honouring a specific proxy header.

```php
add_filter( 'perflocale/geo/visitor_ip', function ( string $ip ): string {
	return $_SERVER['HTTP_X_REAL_IP'] ?? $ip;
} );
```

**Parameters:** `string $ip`.  
**File:** `src/Router/GeoRedirect.php`

#### `perflocale/geo/lookup_country`

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

```php
add_filter( 'perflocale/geo/lookup_country', function ( string $country, string $ip ): string {
	if ( ! empty( $_SERVER['HTTP_CF_IPCOUNTRY'] ) ) {
		return strtoupper( (string) $_SERVER['HTTP_CF_IPCOUNTRY'] );
	}
	return $country;
}, 10, 2 );
```

**Parameters:** `string $country` (ISO-3166-1 alpha-2, or empty), `string $ip`.  
**Args:** 2  
**File:** `src/Router/GeoRedirect.php`

### Routing & detection

#### `perflocale/accept_language_limit`

Maximum number of ranked entries parsed out of the `Accept-Language` HTTP header when matching the visitor's preferred language.

```php
add_filter( 'perflocale/accept_language_limit', fn() => 5 );
```

**Parameters:** `int $limit` - Default `20`.  
**File:** `src/Router/LanguageRouter.php`

#### `perflocale/query/child_post_types`

Post types treated as children of their parent (e.g. WooCommerce variations). Query filtering follows the parent's language to avoid split listings.

```php
add_filter( 'perflocale/query/child_post_types', function ( array $types ): array {
	$types[] = 'my_variant_cpt';
	return $types;
} );
```

**Parameters:** `array $types` - Default `[ 'product_variation' ]`.  
**File:** `src/Translation/PostQueryFilter.php`

### Language switcher (menu placement)

#### `perflocale/switcher/menu_locations`

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

```php
add_filter( 'perflocale/switcher/menu_locations', function ( array $locations, \stdClass $args ): array {
	return [ 'primary', 'header-utility' ];
}, 10, 2 );
```

**Parameters:** `array $locations` - the theme location slugs that should receive the switcher. Defaults to the locations ticked on **Settings → Language Switcher** (“Append to classic menus”), kept verbatim so mixed-case slugs like `headerMenu` stay intact; return a wider or narrower set to override, or an empty array to allow every menu. `\stdClass $args` - the current `wp_nav_menu` args.  
**Args:** 2  
**File:** `src/Frontend/LanguageSwitcher.php`

### Machine translation

#### `perflocale/mt/allowed_html`

Filter the `wp_kses`\-style allowed-tags map applied to translated HTML returned by MT providers. The default is **derived from core** rather than hand-maintained: `wp_kses_allowed_html( 'post' )` minus `object`, `textarea` and `button`, plus `source` (which core omits but the `core/video` and `core/audio` blocks emit). Because it tracks core, it doesn't silently strip block markup the way a fixed list does. Tighten or loosen from there.

```php
add_filter( 'perflocale/mt/allowed_html', function ( array $allowed ): array {
	$allowed['mark'] = [];
	return $allowed;
} );
```

**Parameters:** `array $allowed`.  
**File:** `src/MachineTranslation/TranslationService.php`

### Strings & content

#### `perflocale/strings/regenerate_files`

Fires when PerfLocale is about to rebuild the files-mode translation bundles (WordPress 6.5+ `.l10n.php` files). Two paths fire it, both only while `string_translation_mode` is `files`: a settings save that switches string translation to files mode, and a completed bulk string machine-translation job. Hook in to trigger downstream compilation or to piggyback on the same cache-clear window.

```php
add_action( 'perflocale/strings/regenerate_files', function ( \PerfLocale\Cache\CacheManager $cache ): void {
	// Custom follow-up work.
} );
```

**Parameters:** `\PerfLocale\Cache\CacheManager $cache`.  
**File:** `src/Cache/CacheInvalidator.php`, `src/Background/Jobs/BulkStringTranslateJob.php`

#### `perflocale/strings/changed`

Fires whenever PerfLocale writes to the `strings` / `string_translations` tables, so addons that derive state from those tables can invalidate it. Fires from three paths, each passing its origin: the admin Strings screen save (`admin_save`), a PO import (`po_import`, which in `--replace` mode bulk-deletes a language’s translations), and a completed bulk machine-translation job (`bulk_mt`).

```php
add_action( 'perflocale/strings/changed', function ( string $origin ): void {
	// $origin is 'admin_save', 'po_import', or 'bulk_mt'.
	my_addon_invalidate_string_state( $origin );
} );
```

**Parameters:** `string $origin` - what changed the strings (`admin_save`, `po_import`, or `bulk_mt`).  
**File:** `src/Admin/AdminController.php`, `src/Admin/PoSync.php`, `src/Background/Jobs/BulkStringTranslateJob.php`

#### `perflocale/translation/dangerous_meta_patterns`

Meta-key fragments that must never be copied onto a translation - credentials, tokens and other secrets. Despite the name these are **not** regular expressions: every entry is tested with a case-insensitive substring match (`stripos()`) against the meta key, so `_token` also matches `_my_plugin_TOKEN_v2`. Wrapping a pattern in regex delimiters makes it match nothing. Defaults: `_password`, `_secret`, `_token`, `_api_key`, `_apikey`, `stripe`, `paypal`, `_credentials`, `_encrypted`, `_auth`. Applied to the post-meta copy performed when a translation is created.

```php
add_filter( 'perflocale/translation/dangerous_meta_patterns', function ( array $patterns ): array {
	// Substring, not regex - matched case-insensitively anywhere in the key.
	$patterns[] = '_my_encrypted_';
	return $patterns;
} );
```

**Parameters:** `array $patterns` - key fragments, matched case-insensitively as substrings.  
**File:** `src/Translation/PostTranslationManager.php`

### Cache & SEO

#### `perflocale/cache/flush_archive_hreflang`

Fires when the archive hreflang cache is invalidated, after PerfLocale’s own wipe. Fires from two paths - a post whose status / language assignment changed, and a term edit (which also flushes the term’s translation siblings) - so the id is a post ID _or_ a term ID. Full description: [perflocale/cache/flush\_archive\_hreflang](#perflocale-cache-flush-archive-hreflang) above.

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

**Parameters:** `int $object_id` — the post ID or term ID whose save / edit / delete triggered the flush. The hook does not distinguish the two.  
**File:** `src/Cache/CacheInvalidator.php`

### Admin & permissions

#### `perflocale/abilities/enabled`

Opt in to PerfLocale's WordPress Abilities API integration. The Abilities API ships in WordPress 6.9 and exposes plugin operations as discoverable, REST-callable, AI-tool-friendly abilities. PerfLocale registers six abilities in the `perflocale-translation` category, but they are **off by default** — they only register when this filter returns true. That keeps zero-overhead behaviour for installs that don't need them.

```
// Enable the integration. Required on WordPress 6.9+ for the abilities to appear.
add_filter( 'perflocale/abilities/enabled', '__return_true' );
```

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

-   `perflocale/list-languages` — return all configured languages.
-   `perflocale/get-translations` — fetch the translation group for a post.
-   `perflocale/detect-language` — detect the language of a piece of text.
-   `perflocale/convert-url` — convert a URL between languages.
-   `perflocale/translate-post` — machine-translate a post into a target language.
-   `perflocale/create-translation` — create a new translation linked to an existing post.

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

**Parameters:** `bool $enabled` - Default `false`.  
**File:** `src/Bootstrap.php`

#### `perflocale/block_toolbar/enabled`

Toggle PerfLocale's block-editor toolbar button for flagging a block as non-translatable.

```php
add_filter( 'perflocale/block_toolbar/enabled', '__return_false' );
```

**Parameters:** `bool $enabled` - Default `true`.  
**File:** `src/Bootstrap.php`

#### `perflocale/menu/badge_post_limit`

Upper bound on the number of published posts/pages loaded when PerfLocale draws the per-language badges on the **Appearance → Menus** screen (the language codes appended to items in the “Add to Menu” metabox). Rows are ordered by `post_modified` DESC, so the most recently edited content keeps its badge on very large sites.

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

**Parameters:** `int $limit` - Default `5000`. `0` means unlimited (the `LIMIT` clause is dropped entirely - use carefully).  
**File:** `src/Translation/MenuManager.php`

#### `perflocale/migration/time_limit`

Override the `set_time_limit()` value used during the TranslatePress migration import.

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

**Parameters:** `int $seconds` - Default `300`.  
**File:** `src/Migration/TranslatePressImporter.php`

#### `perflocale/admin/bulk_time_limit`

Override the `set_time_limit()` value used by the two bulk admin AJAX handlers that raise it: _Create taxonomy translations_ (`perflocale_create_taxonomy_translations`) and _Assign post languages_ (`perflocale_assign_post_languages`). The _Create WooCommerce page translations_ handler does not change the time limit, so this filter has no effect there. Both handlers are gated by `check_ajax_referer()` + `current_user_can('manage_options')`, and the time-limit raise happens only AFTER those gates pass. Default `0` (no limit). Set a positive int (seconds) if your host enforces a hard cap and you'd rather error out cleanly than have the handler abort mid-batch.

```
// Cap the bulk admin handlers at 10 minutes on this host.
add_filter( 'perflocale/admin/bulk_time_limit', fn(): int => 600 );
```

**Parameters:** `int $seconds` - Default `0` (no limit).  
**File:** `src/Bootstrap.php`

### Webhooks

#### `perflocale/webhooks/url_safe`

Final gate before a webhook URL is dispatched - return `false` to reject. The built-in checks already block private/loopback/metadata IPs; use this filter to layer org-specific policy on top.

```php
add_filter( 'perflocale/webhooks/url_safe', function ( bool $safe, string $url ): bool {
	$host = wp_parse_url( $url, PHP_URL_HOST );
	return $safe && in_array( $host, [ 'hooks.my-org.example' ], true );
}, 10, 2 );
```

**Parameters:** `bool $safe`, `string $url`.  
**Args:** 2  
**File:** `src/Api/WebhookController.php`
