---
title: "Developer API - Extending from External Plugins - PerfLocale"
description: "How third-party plugins can extend PerfLocale without modifying its source files: hooks, REST endpoints, WP-CLI, and the addon system."
canonical: "https://perflocale.com/docs/developer-api/"
source: "https://perflocale.com/docs/developer-api/"
format: "markdown"
---
# 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](https://perflocale.com/docs/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
<?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

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

**Register-time contract:** `AddonRegistry::register()` runs seven checks on every addon. Four reject in production (invalid `get_id()`, registration after the final addon boot pass at `plugins_loaded:99`, conflict with a bundled-addon id, or a duplicate registration). Three more are `WP_DEBUG`\-only nudges (empty `get_name()`, malformed `get_version()`, non-array `get_required_plugins()`). All seven emit `_doing_it_wrong()` — none crash the site. See [AddonRegistry contract validation](https://perflocale.com/docs/addon-system/developer-toolkit/#contract) for the full table.

**Addons page:** Registered addons automatically appear on the Addons page. To control the card (category, icon, description, settings link), implement the `PerfLocale\Addon\HasCardInfo` interface on your addon class — a bare `get_card_info()` method without the interface is ignored:

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

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

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

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

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

PerfLocale ships **no bundled exchange-rate provider**. WooCommerce multi-currency and its scheduled sync are there, but until you register a rate source the plugin has nothing to fetch from and contacts nothing. Wire one up with this filter (or return the rates directly from `perflocale/woocommerce/exchange_rates_fetched`).

```php
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;
} );
```

### Related hooks

| Hook | Type | Description |
| --- | --- | --- |
| perflocale/woocommerce/exchange_rates_fetched | Filter | Modify rates before saving |
| perflocale/woocommerce/exchange_rates_synced | Action | Fires after rates are saved |

## Custom GeoIP Provider

Same story on the geo side: the GeoIP redirect feature ships with **no provider and no endpoint**, so it contacts nothing until you give it a source. Register a lookup service with `perflocale/geo/providers`, or skip the provider layer entirely with `perflocale/geo/lookup_country` if your host or CDN already hands you a country header.

```php
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;
} );
```

### Related hooks

| Hook | Type | Description |
| --- | --- | --- |
| perflocale/geo/visitor_ip | Filter | Override IP detection (useful for proxies/CDNs) |
| perflocale/geo/country_code | Filter | Modify detected country code |
| perflocale/geo/country_map | Filter | Modify country-to-language mapping |
| perflocale/geo/redirect_language | Filter | Override redirect language selection |
| perflocale/geo/lookup_country | Filter | Bypass providers entirely with custom lookup |

## Custom Machine Translation Provider

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

```php
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( \PerfLocale\Plugin::get_instance()->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.

### Related hooks

| Hook | Type | Description |
| --- | --- | --- |
| perflocale/machine_translation/text_before_send | Filter | Modify text before sending to provider |
| perflocale/machine_translation/result | Filter | Modify translated result |
| perflocale/machine_translation/before | Action | Fires before translation begins |
| perflocale/machine_translation/after | Action | Fires after translation succeeds |
| perflocale/mt/translatable_meta_keys | Filter | Meta keys to machine-translate |
| perflocale/mt/meta_translate_failed | Action | Fires when meta values fail to translate |
| perflocale/machine_translation/failed | Action | Fires 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;
} );
```

### Template-tag timing

The public template tags (`perflocale_current_language()`, `perflocale_get_languages()`, `perflocale_get_permalink()`, …) require PerfLocale to have finished booting. Calling them before the `plugins_loaded` action has fired emits `_doing_it_wrong()` (dev-mode) and returns a safe empty/default value — call them from `init` or later.

**Safe to call from:**

-   `after_setup_theme` (priority 10+)
-   `init`
-   `wp` and any later hook (template rendering, REST callbacks, AJAX handlers, etc.)

**Too early:**

-   `muplugins_loaded`
-   Plugin top-level scope (PHP code outside any hook callback)
-   `plugins_loaded` at priority below 10

The internal helper `perflocale_doing_it_wrong_too_early( string $function ): bool` is what each template tag calls to make this check — it is `@internal` and not a public extension point.

### 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 it owns — the machine-translation providers and the custom agency endpoint — 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

// Custom agency endpoint
PERFLOCALE_AGENCY_URL
PERFLOCALE_AGENCY_API_KEY
```

That is the complete list — seven names. A custom geo or exchange-rate provider registered through the filters above owns its own credentials; it can read them from wherever it likes, including its own constants.

See the dedicated [API Keys: Environment Variables & Constants](https://perflocale.com/docs/api-key-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

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/addons/register | Action | AddonRegistry $registry | Register external addons |
| perflocale/addons/registered | Filter | array $addons | Modify addons before boot |
| perflocale/addon/is_compatible | Filter | bool $compatible, string $id | Override compatibility |
| perflocale/addon/activated | Action | string $id | After addon boots |
| perflocale/addons/loaded | Action | - | After all addons boot |

### Settings

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/settings/addon_subtabs | Filter | array $subtabs | Add settings subtabs |
| perflocale/settings/render_addon_subtab | Action | string $subtab, Settings $settings | Render subtab UI |
| perflocale/settings/addon_subtab_after | Action | string $subtab, Settings $settings | After form-table |
| perflocale/settings/extract_addon_values | Filter | array $values, string $tab | Provide save values |
| perflocale/settings/updated | Action | array $new, array $old | After settings saved |

### Language & Routing

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/language/detected | Action | string $slug, string $method | After language detected |
| perflocale/active_languages | Filter | array $languages | Filter active languages |
| perflocale/excluded_paths | Filter | array $paths | URL paths to skip |
| perflocale/url/convert | Filter | string $url, string $target_slug, string $current_slug | Filter converted URLs |

### Content & Translation

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/translatable_post_types | Filter | array $types | Translatable post types |
| perflocale/translatable_taxonomies | Filter | array $taxes | Translatable taxonomies |
| perflocale/translation/created | Action | int $new_id, string $type, string $target_slug, int $source_id | Translation created |
| perflocale/sync_fields | Filter | array $fields, string $post_type | Fields to sync across translations |

### Frontend

Summary of every switcher hook. See the [Hooks reference](https://perflocale.com/docs/hooks/#language-switcher) for full parameter signatures and examples, and the [Language Switcher](https://perflocale.com/docs/language-switcher/#javascript-events) page for the matching JS events.

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/switcher/languages | Filter | array $languages | Language list shown in the switcher |
| perflocale/switcher/link_attrs | Filter | array $attrs, object $lang, string $current_slug, array $args | HTML attributes on each per-language link (data-*, rel, hreflang override, lang/dir override, …) |
| perflocale/switcher/option_content | Filter | string $inner, object $lang, string $current_slug, array $attrs | Inner HTML of each option (flag span + label span), fires from every render surface |
| perflocale/switcher/panel_before | Filter | string $html, array $languages, string $current_slug, array $attrs | HTML injected at the start of the dropdown panel (inside the listbox) |
| perflocale/switcher/panel_after | Filter | string $html, array $languages, string $current_slug, array $attrs | HTML injected at the end of the dropdown panel |
| perflocale/switcher/arrow_html | Filter | string $html, string $style | Override the dropdown trigger's chevron icon |
| perflocale/switcher/kses_allowed_html | Filter | array $allowed, string $html | Extend the kses_switcher() allowlist with extra tags / attributes |
| perflocale/switcher/output | Filter | string $html, array $args | Last-resort hook on the fully-rendered switcher HTML |
| perflocale/switcher/add_to_menu | Filter | bool $add, \stdClass $args | Auto-inject the switcher into a registered theme menu location |
| perflocale/switcher/menu_locations | Filter | array $locations, \stdClass $args | Restrict menu auto-injection to specific theme menu locations |
| perflocale/seo/hreflang_tags | Filter | array $tags | Hreflang tags emitted in the HTML head + HTTP Link header |

Three bubbling `CustomEvent`s 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](https://perflocale.com/docs/language-switcher/#javascript-events) for detail-object shapes + usage examples.

### WooCommerce

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/woocommerce/exchange_rate_providers | Filter | array $providers | Rate providers |
| perflocale/woocommerce/exchange_rates_fetched | Filter | array $rates, string $base, string $provider | Before save |
| perflocale/woocommerce/exchange_rates_synced | Action | array $rates, string $base, string $provider | After save |

### GeoIP

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/geo/providers | Filter | array $providers | GeoIP providers |
| perflocale/geo/visitor_ip | Filter | string $ip | Override IP |
| perflocale/geo/country_code | Filter | string $code, string $ip | Override country |
| perflocale/geo/country_map | Filter | array $map | Country-to-language map |
| perflocale/geo/redirect_language | Filter | string $slug, string $country, string $ip | Override redirect |

### Machine Translation

| Hook | Type | Args | Description |
| --- | --- | --- | --- |
| perflocale/machine_translation/providers | Filter | array $providers | MT providers |
| perflocale/machine_translation/text_before_send | Filter | string $text, string $provider_id, string $target_lang | Pre-send filter |
| perflocale/machine_translation/result | Filter | string $translated, string $original, string $provider_id | Post-translate filter |

### Plugin Lifecycle

| Hook | Type | Description |
| --- | --- | --- |
| perflocale/loaded | Action | Plugin fully booted |
| perflocale/activated | Action | Plugin activated |
| perflocale/deactivated | Action | Plugin deactivated |
| perflocale/cache/flush_all | Action | All 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:

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

When enabled, PerfLocale registers these abilities:

| Ability | Type | Permission | Description |
| --- | --- | --- | --- |
| perflocale/list-languages | Read | Public | List all active languages |
| perflocale/get-translations | Read | perflocale_translate | Get all language versions of a post or term |
| perflocale/detect-language | Read | perflocale_translate | Detect what language a post or term is in |
| perflocale/convert-url | Read | Logged-in (filterable) | Convert a URL to a different language |
| perflocale/translate-post | Mutation | perflocale_use_mt | Machine-translate a post |
| perflocale/create-translation | Mutation | perflocale_translate | Create 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.
