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 stores API keys (DeepL, Google Translate, Fixer.io, etc.) in wp_options. For production environments, define keys as PHP constants in wp-config.php instead - PerfLocale checks constants first and skips database storage when they're defined.

Supported constants

// wp-config.php

// Machine Translation providers
define( 'PERFLOCALE_MT_DEEPL_API_KEY', 'your-deepl-key' );
define( 'PERFLOCALE_MT_GOOGLE_API_KEY', 'your-google-key' );
define( 'PERFLOCALE_MT_MICROSOFT_API_KEY', 'your-microsoft-key' );
define( 'PERFLOCALE_MT_LIBRE_API_KEY', 'your-libre-key' );
define( 'PERFLOCALE_MT_LIBRE_URL', 'https://your-libre-instance.com' );

// Exchange rate providers
define( 'PERFLOCALE_WC_OXR_API_KEY', 'your-openexchangerates-key' );
define( 'PERFLOCALE_WC_CURRENCYFREAKS_KEY', 'your-currencyfreaks-key' );
define( 'PERFLOCALE_WC_FIXER_KEY', 'your-fixer-key' );

// GeoIP providers
define( 'PERFLOCALE_GEO_IPINFO_KEY', 'your-ipinfo-key' );
define( 'PERFLOCALE_GEO_IPSTACK_KEY', 'your-ipstack-key' );

Why this matters

  • Database backups won't expose API keys
  • Staging/dev environments can use different keys via environment-specific wp-config.php
  • wp-admin access doesn't reveal keys (the settings field shows "Defined in wp-config.php")
  • Version control - wp-config.php is typically excluded from repos

When a constant is defined, the corresponding settings field becomes read-only and the value is never written to the database.

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

HookTypeArgsDescription
perflocale/switcher/languagesFilterarray $languagesSwitcher languages
perflocale/switcher/outputFilterstring $htmlSwitcher HTML
perflocale/switcher/add_to_menuFilterbool $addAdd switcher to menu
perflocale/seo/hreflang_tagsFilterarray $tagsHreflang tags

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.