Developer API

This document covers how third-party plugins can extend PerfLocale without modifying its source files. All extension points use standard WordPress filters and actions.

Building an addon that owns its own database tables? See the dedicated Addon System page for the HasSchema, HasUninstallTargets, and HasCustomUninstall capability interfaces - they give your addon managed migrations, a manifest-driven uninstall, and strict namespace isolation, all with four worked examples covering schema, meta + cron, custom cleanup callbacks, and multisite.

Registering an External Addon

External plugins can register as first-class PerfLocale addons without placing files in the addons/ directory.

Step 1: Implement AddonInterface

<?php
// my-perflocale-addon/my-perflocale-addon.php
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Plugin;

class MyPerfLocaleAddon implements AddonInterface {

	public function get_id(): string {
		return 'my-addon';
	}

	public function get_name(): string {
		return 'My Addon';
	}

	public function get_version(): string {
		return '1.0.0';
	}

	public function get_required_plugins(): array {
		// Return empty array if no dependencies.
		return [];
	}

	public function is_compatible(): bool {
		// Return true if this addon should activate.
		return true;
	}

	public function boot( Plugin $plugin ): void {
		// Register your hooks here. $plugin provides access to:
		// $plugin->get( 'settings' ) - Settings instance
		// $plugin->get( 'router' ) - Language router
		// $plugin->get( 'cache' ) - Cache manager
	}

	public function get_settings_fields(): array {
		return [];
	}
}

Step 2: Register on the perflocale/addons/register action

add_action( 'perflocale/addons/register', function ( $registry ) {
	$registry->register( new MyPerfLocaleAddon() );
} );

Timing: This action fires at plugins_loaded priority 20, after all plugins are active. Your plugin's main file runs before this, so the hook is always available.

Safety: The addon's boot() runs inside a try/catch - a crash in your addon won't take down the site (errors are logged when WP_DEBUG is enabled).

Addons page: Registered addons automatically appear on the Addons page. To control the card (category, icon, description, settings link), add a get_card_info() method to your addon class:

public function get_card_info(): array {
	return [
		'description' => __( 'Does amazing things.', 'my-textdomain' ),
		'category' => 'seo', // feature, theme, seo, ecommerce, builder, fields, forms
		'icon' => 'dashicons-star-filled',
		'requires' => __( 'My Plugin v1.0', 'my-textdomain' ),
		'settings_tab' => 'my-addon', // Slug - links to Settings > Addons > my-addon
	];
}

All keys are optional - unset keys use sensible defaults (category defaults to feature, icon to dashicons-admin-plugins).

Available categories: feature, theme, seo, ecommerce, builder, fields, forms.

Alternatively, use the perflocale/addons/registry filter for full control without implementing the interface:

add_filter( 'perflocale/addons/registry', function ( array $addons ): array {
	$addons['my-addon'] = [
		'name' => 'My Addon',
		'description' => __( 'Does amazing things.', 'my-textdomain' ),
		'category' => 'seo',
		'icon' => 'dashicons-star-filled',
		'requires' => __( 'My Plugin v1.0', 'my-textdomain' ),
		'settings_tab' => 'my-addon',
		'check' => fn() => true,
	];
	return $addons;
} );

Adding a Settings Subtab

Add a settings page under Settings > Addons with your own form fields.

Step 1: Register the subtab

add_filter( 'perflocale/settings/addon_subtabs', function ( array $subtabs ): array {
	$subtabs['my-addon'] = __( 'My Addon', 'my-textdomain' );
	return $subtabs;
} );

Step 2: Render your settings fields

add_action( 'perflocale/settings/render_addon_subtab', function ( string $subtab, $settings ) {
	if ( $subtab !== 'my-addon' ) {
		return;
	}
	?>
	<tr>
	<th scope="row"><?php esc_html_e( 'API Key', 'my-textdomain' ); ?></th>
	<td>
	<input type="text" name="my_addon_api_key"
	value="<?php echo esc_attr( $settings->get( 'my_addon_api_key', '' ) ); ?>">
	</td>
	</tr>
	<?php
}, 10, 2 );

Step 3: Handle saving

add_filter( 'perflocale/settings/extract_addon_values', function ( array $values, string $tab ): array {
	if ( $tab !== 'my-addon' ) {
		return $values;
	}
	return [
		'my_addon_api_key' => isset( $_POST['my_addon_api_key'] )
		? sanitize_text_field( wp_unslash( $_POST['my_addon_api_key'] ) )
		: '',
	];
}, 10, 2 );

Important: Your setting keys must be registered in PerfLocale's Settings::DEFAULTS array, or use WordPress update_option() directly for custom storage.

Custom Exchange Rate Provider

Add a custom exchange rate data source for multi-currency.

add_filter( 'perflocale/woocommerce/exchange_rate_providers', function ( array $providers ): array {
	$providers['my_rates_api'] = [
		'name' => 'My Rates API',
		'needs_key' => true,
		'key_setting' => 'wc_my_rates_key',
		'fetch_callback' => function ( string $base, array $targets, string $api_key ): array {
			$response = wp_remote_get( 'https://api.example.com/rates?' . http_build_query( [
				'base' => $base,
				'symbols' => implode( ',', $targets ),
				'key' => $api_key,
			] ) );

			if ( is_wp_error( $response ) ) {
				return [];
			}

			$data = json_decode( wp_remote_retrieve_body( $response ), true );

			// Return: [ 'GBP' => 0.87, 'BGN' => 1.96 ]
			return $data['rates'] ?? [];
		},
	];

	return $providers;
} );
HookTypeDescription
perflocale/woocommerce/exchange_rates_fetchedFilterModify rates before saving
perflocale/woocommerce/exchange_rates_syncedActionFires after rates are saved

Custom GeoIP Provider

Add a custom geolocation service for visitor country detection.

add_filter( 'perflocale/geo/providers', function ( array $providers ): array {
	$providers['my_geoip'] = [
		'name' => 'My GeoIP Service',
		'needs_key' => true,
		'key_setting' => 'geo_my_geoip_key',
		'fetch_callback' => function ( string $ip, $settings ): string {
			$response = wp_remote_get( "https://geo.example.com/{$ip}" );

			if ( is_wp_error( $response ) ) {
				return '';
			}

			$data = json_decode( wp_remote_retrieve_body( $response ), true );

			return $data['country_code'] ?? '';
		},
	];

	return $providers;
} );
HookTypeDescription
perflocale/geo/visitor_ipFilterOverride IP detection (useful for proxies/CDNs)
perflocale/geo/country_codeFilterModify detected country code
perflocale/geo/country_mapFilterModify country-to-language mapping
perflocale/geo/redirect_languageFilterOverride redirect language selection
perflocale/geo/lookup_countryFilterBypass providers entirely with custom lookup

Custom Machine Translation Provider

Extend AbstractProvider to add a custom MT engine (e.g., a self-hosted model).

use PerfLocale\MachineTranslation\AbstractProvider;

class MyMtProvider extends AbstractProvider {

	public function get_id(): string { return 'my_mt'; }
	public function get_name(): string { return 'My Translation API'; }

	public function is_configured(): bool {
		return (string) $this->settings->get( 'mt_my_api_key' ) !== '';
	}

	/**
	 * @param bool $fast_fail When true, use a short timeout - called from
	 * synchronous REST contexts (block editor, REST API).
	 */
	public function translate( string $text, string $source_lang, string $target_lang, bool $fast_fail = false ): string {
		$response = $this->make_request(
			'https://mt.example.com/translate',
			[
				'method' => 'POST',
				'headers' => [
					'Authorization' => 'Bearer ' . $this->settings->get( 'mt_my_api_key' ),
					'Content-Type' => 'application/json',
				],
				'body' => wp_json_encode( [ 'text' => $text, 'target' => $target_lang ] ),
				'timeout' => 30,
			],
			3, // retries
			$fast_fail // pass through - make_request() enforces 1 retry + 8 s cap
		);

		$data = $this->parse_json_response( $response['body'] );
		$this->track_usage( $text );

		return (string) ( $data['translation'] ?? $text );
	}

	public function test_connection(): bool {
		$this->translate( 'test', 'en', 'es' );
		return true;
	}
}

add_filter( 'perflocale/machine_translation/providers', function ( array $providers ): array {
	$providers['my_mt'] = new MyMtProvider( $settings );
	return $providers;
} );

Important: implement ProviderInterface (or extend AbstractProvider) and always accept and pass through the $fast_fail parameter in both translate() and translate_batch(). When $fast_fail = true the calling context is a synchronous REST request (block editor, machine-translate REST endpoint) and the user is waiting - your provider should fail fast rather than retrying.

HookTypeDescription
perflocale/machine_translation/text_before_sendFilterModify text before sending to provider
perflocale/machine_translation/resultFilterModify translated result
perflocale/machine_translation/beforeActionFires before translation begins
perflocale/machine_translation/afterActionFires after translation succeeds
perflocale/machine_translation/failedActionFires on translation failure

Theme Integration

Themes can register a language switcher element or declare compatibility.

Adding translatable content types

// Register a custom post type as translatable.
add_filter( 'perflocale/translatable_post_types', function ( array $types ): array {
	$types[] = 'my_custom_post_type';
	return $types;
} );

// Register a custom taxonomy as translatable.
add_filter( 'perflocale/translatable_taxonomies', function ( array $taxes ): array {
	$taxes[] = 'my_custom_taxonomy';
	return $taxes;
} );

Customizing the language switcher

// Filter languages shown in the switcher.
add_filter( 'perflocale/switcher/languages', function ( array $languages ): array {
	// Remove a language from the switcher.
	unset( $languages['ar'] );
	return $languages;
} );

// Filter the final switcher HTML.
add_filter( 'perflocale/switcher/output', function ( string $html ): string {
	return '<div class="my-wrapper">' . $html . '</div>';
} );

Securing API Keys

PerfLocale resolves every API key (DeepL, Google Translate, Fixer.io, GeoIP providers, etc.) from three sources in priority order:

  1. Environment variable — e.g. PERFLOCALE_DEEPL_API_KEY. Read via getenv(). Highest priority. Recommended for containerised, CI, or managed-host deployments.
  2. PHP constant in wp-config.php — same canonical name (define( 'PERFLOCALE_DEEPL_API_KEY', '…' )). Recommended when env vars aren’t available.
  3. Admin settings (database) — lowest priority, used when neither override is set.

The first non-empty source wins. When an env var or constant is active, the matching admin field is disabled and shows where the value comes from; the value is never written to the database or included in PerfLocale’s data exports.

Supported canonical names

// Each name is used identically as both env-var name and PHP-constant name.

// Machine translation
PERFLOCALE_DEEPL_API_KEY
PERFLOCALE_GOOGLE_API_KEY
PERFLOCALE_MICROSOFT_API_KEY
PERFLOCALE_LIBRE_API_KEY
PERFLOCALE_LIBRE_URL
PERFLOCALE_AGENCY_URL
PERFLOCALE_AGENCY_API_KEY

// GeoIP providers
PERFLOCALE_IPINFO_TOKEN
PERFLOCALE_IPINFO_LITE_TOKEN
PERFLOCALE_IPSTACK_KEY
PERFLOCALE_IP_API_KEY

// Exchange-rate providers
PERFLOCALE_OXR_API_KEY
PERFLOCALE_CURRENCYFREAKS_KEY
PERFLOCALE_FIXER_KEY

See the dedicated API Keys: Environment Variables & Constants reference for examples and the full priority semantics.

Why this matters

  • Database backups won’t expose API keys
  • Staging/dev environments can use different keys via per-environment env vars or wp-config.php includes
  • wp-admin access doesn’t reveal keys (the settings field is disabled and shows the source)
  • Version control — both env vars and wp-config.php can be excluded from repos

Hooks Reference

Addon Lifecycle

HookTypeArgsDescription
perflocale/addons/registerActionAddonRegistry $registryRegister external addons
perflocale/addons/registeredFilterarray $addonsModify addons before boot
perflocale/addon/is_compatibleFilterbool $compatible, string $idOverride compatibility
perflocale/addon/activatedActionstring $idAfter addon boots
perflocale/addons/loadedAction-After all addons boot

Settings

HookTypeArgsDescription
perflocale/settings/addon_subtabsFilterarray $subtabsAdd settings subtabs
perflocale/settings/render_addon_subtabActionstring $subtab, Settings $settingsRender subtab UI
perflocale/settings/addon_subtab_afterActionstring $subtab, Settings $settingsAfter form-table
perflocale/settings/extract_addon_valuesFilterarray $values, string $tabProvide save values
perflocale/settings/updatedActionarray $new, array $oldAfter settings saved

Language & Routing

HookTypeArgsDescription
perflocale/language/detectedActionstring $slug, string $methodAfter language detected
perflocale/language/switchedActionstring $slugAfter programmatic switch
perflocale/active_languagesFilterarray $languagesFilter active languages
perflocale/excluded_pathsFilterarray $pathsURL paths to skip
perflocale/url/convertFilterstring $urlFilter converted URLs

Content & Translation

HookTypeArgsDescription
perflocale/translatable_post_typesFilterarray $typesTranslatable post types
perflocale/translatable_taxonomiesFilterarray $taxesTranslatable taxonomies
perflocale/translation/createdActionint $id, string $type, string $langTranslation created
perflocale/sync_fieldsFilterarray $fieldsFields to sync across translations

Frontend

Summary of every switcher hook. See the Hooks reference for full parameter signatures and examples, and the Language Switcher page for the matching JS events.

HookTypeArgsDescription
perflocale/switcher/languagesFilterarray $languagesLanguage list shown in the switcher
perflocale/switcher/link_attrsFilterarray $attrs, object $lang, string $current_slug, array $argsHTML attributes on each per-language link (data-*, rel, hreflang override, lang/dir override, …)
perflocale/switcher/option_contentFilterstring $inner, object $lang, string $current_slug, array $attrsInner HTML of each option (flag span + label span), fires from every render surface
perflocale/switcher/panel_beforeFilterstring $html, array $languages, string $current_slug, array $attrsHTML injected at the start of the dropdown panel (inside the listbox)
perflocale/switcher/panel_afterFilterstring $html, array $languages, string $current_slug, array $attrsHTML injected at the end of the dropdown panel
perflocale/switcher/arrow_htmlFilterstring $html, string $styleOverride the dropdown trigger's chevron icon
perflocale/switcher/sanitize_outputFilterbool $sanitize, string $htmlToggle whether kses_switcher() runs wp_kses (default true)
perflocale/switcher/kses_allowed_htmlFilterarray $allowed, string $htmlExtend the kses_switcher() allowlist with extra tags / attributes
perflocale/switcher/outputFilterstring $html, array $argsLast-resort hook on the fully-rendered switcher HTML
perflocale/switcher/add_to_menuFilterbool $add, \stdClass $argsAuto-inject the switcher into a registered theme menu location
perflocale/switcher/menu_locationsFilterarray $locations, \stdClass $argsRestrict menu auto-injection to specific theme menu locations
perflocale/seo/hreflang_tagsFilterarray $tagsHreflang tags emitted in the HTML head + HTTP Link header

Three bubbling CustomEvents also fire on the switcher's <nav> root for JS integrations: perflocale:switcher:open and perflocale:switcher:close (fired after the panel state changes; not cancelable), and perflocale:switcher:navigate (fired BEFORE the browser follows a clicked option's URL; cancelable via event.preventDefault() for "unsaved changes" guards, pre-switch analytics with navigator.sendBeacon, custom redirect middleware, A/B testing tools). See JavaScript events for detail-object shapes + usage examples.

WooCommerce

HookTypeArgsDescription
perflocale/woocommerce/exchange_rate_providersFilterarray $providersRate providers
perflocale/woocommerce/exchange_rates_fetchedFilterarray $rates, string $base, string $providerBefore save
perflocale/woocommerce/exchange_rates_syncedActionarray $rates, string $base, string $providerAfter save

GeoIP

HookTypeArgsDescription
perflocale/geo/providersFilterarray $providersGeoIP providers
perflocale/geo/visitor_ipFilterstring $ipOverride IP
perflocale/geo/country_codeFilterstring $code, string $ipOverride country
perflocale/geo/country_mapFilterarray $mapCountry-to-language map
perflocale/geo/redirect_languageFilterstring $slug, string $countryOverride redirect

Machine Translation

HookTypeArgsDescription
perflocale/machine_translation/providersFilterarray $providersMT providers
perflocale/machine_translation/text_before_sendFilterstring $textPre-send filter
perflocale/machine_translation/resultFilterstring $resultPost-translate filter

Plugin Lifecycle

HookTypeDescription
perflocale/loadedActionPlugin fully booted
perflocale/activatedActionPlugin activated
perflocale/deactivatedActionPlugin deactivated
perflocale/cache/flush_allActionAll caches flushed

WordPress Abilities API

PerfLocale integrates with the WordPress Abilities API (introduced in WordPress 6.9). This makes PerfLocale's core translation operations discoverable and executable by AI tools, external consumers, and the standardized Abilities REST API.

The integration is disabled by default. Enable it with a filter:

add_filter( 'perflocale/abilities/enabled', '__return_true' );

When enabled, PerfLocale registers these abilities:

AbilityTypePermissionDescription
perflocale/list-languagesReadPublicList all active languages
perflocale/get-translationsReadperflocale_translateGet all language versions of a post or term
perflocale/detect-languageReadperflocale_translateDetect what language a post or term is in
perflocale/convert-urlReadPublicConvert a URL to a different language
perflocale/translate-postMutationperflocale_use_mtMachine-translate a post
perflocale/create-translationMutationperflocale_translateCreate a translation stub

Each ability includes JSON Schema input/output validation, proper permission callbacks, and MCP annotations for AI agent integration. On WordPress versions below 6.9, this feature has zero overhead - the hooks simply never fire.