---
title: "Addon Developer Toolkit - PerfLocale"
description: "The API PerfLocale exposes to addons: concurrency locks, circuit breakers, background jobs, a typed service container and the fluent Helper API."
canonical: "https://perflocale.com/docs/addon-system/developer-toolkit/"
source: "https://perflocale.com/docs/addon-system/developer-toolkit/"
format: "markdown"
---
# Developer Toolkit for Addons

The primitives PerfLocale marks `@api` and commits to for the 1.x line. Locks, breakers, background jobs, addon settings, the DI container, and the fluent Helper.

## Cheat sheet — addon best practices

The whole reference is below; this section pulls together the decisions you’ll most often have to make, with one-line answers and a pointer to the deeper section.

-   **Settings UI: auto-form or full-takeover?** Default to auto-form. Declare fields via [`get_settings_fields()`](#settings) and PerfLocale renders, sanitises, and saves them for you. Reach for the [full subtab takeover](#full-subtab-takeover) (the `addon_subtabs` / `render_addon_subtab` / `extract_addon_values` hook trio) only when your UI is genuinely too bespoke (matrices, per-row AJAX, conditional providers — the bundled WooCommerce subtab is the canonical example).
-   **Where does my setting’s value live?** By default in `perflocale_addon_settings` (per-addon, lazy-loaded, 16 KiB cap). For fields that the main plugin runtime + REST APIs already read from `perflocale_settings` (most operators won’t hit this case — it’s for bundled integrations), declare [`'storage' => 'global'`](#global-storage) so the framework skips auto-seeding a dead duplicate.
-   **Bespoke field shape (colour picker, matrix, repeater)?** Use [`'type' => 'custom'`](#custom-field-type) with a `render_callback` + `sanitize_callback`. Both callbacks receive `($addon_id, $field_key, $field_def)`. Omit the sanitize callback to tell the framework “leave this stored value alone” — useful when your addon writes the value from its own AJAX endpoint.
-   **Conditional field visibility?** Add [`'show_if' => [ 'driver' => expected ]`](#settings) (implicit AND) or nest `[ 'op' => 'AND'|'OR', 'rules' => [...] ]`. Evaluated server-side at render and kept in sync client-side automatically.
-   **Settings reads in a hot path?** Use [`AddonSettings::get_many( $id, $keys, $defaults )`](#settings) instead of N `get()` calls — one in-memory cache lookup. The whole option auto-loads once per request, so subsequent reads are free.
-   **Defaults?** Declare `'default' => …` on each field. PerfLocale auto-seeds them on first boot so `AddonSettings::get( $id, $key )` returns the documented default without callers passing one.
-   **Schema / tables?** Implement [`HasSchema`](#capabilities) with `get_schema()` returning `[ short_name => SQL body string ]`. The framework wraps each with the `wp_perflocale_addon_{id}_` prefix and runs dbDelta. Bump `get_schema_version()` and handle the new version number inside `migrate_to( int $target_version )` for breaking changes.
-   **Uninstall cleanup?** Implement [`HasUninstallTargets`](#capabilities) with `get_uninstall_targets()` listing your tables / options / meta / caps / cron hooks. The framework wipes everything on plugin uninstall. Every name must start with `perflocale_` (or `_perflocale_` for meta).
-   **Listener throws on save?** Doesn’t corrupt state — the write commits before hooks fire. But **do NOT** call `AddonSettings::set / set_addon / forget` from inside `perflocale/addon/settings/{before,after}_save` — the lock is non-reentrant and the inner write fails immediately (returns `false`). Defer cross-addon writes via `wp_schedule_single_event`.
-   **Writer returned `false`?** Four cases: invalid addon id (must match `/^[a-z0-9_-]{2,16}$/` — hyphens permitted so the bundled `contact-form-7` / `beaver-builder` / `gravity-forms` ids pass), payload exceeded the 16 KiB per-addon cap, lock contention (rare; retry on next tick), or the option write itself failed. Detail in the [Failure modes](#settings-failure-modes) section.
-   **Repeated boot failures?** The registry quarantines an addon after 3 consecutive throws to keep one broken addon from spiking CPU on every request. Operators see the last error inline on the Addons admin card. Raise the threshold (or set 0 to disable quarantine) via the `perflocale/addons/quarantine_threshold` filter.
-   **Need to be skipped on old hosts?** Implement [`HasVersionRequirement`](#version-requirement) returning the minimum PerfLocale version. Older hosts skip your boot and surface a version-mismatch notice — better than fataling.
-   **Addon settings & export/import?** Included automatically, within envelope `format_version` `1`; a bundle without addon settings imports cleanly. The `wp perflocale export` bundle carries every addon’s `perflocale_addon_settings` entry; the importer restores them as-is, so cloning staging → prod keeps addon config. Credentials with shape `*_api_key / *_token / *_key / *_secret / *_password` are redacted on export. Addon settings travel unconditionally, but a full staging → prod bundle also carries sections that name posts and terms by ID: importing one on a site with a different address asks you to confirm the copy first ([what travels between sites](https://perflocale.com/docs/export-import/#cross-site-id-remapping)).

Your addon's `boot( Plugin $plugin )` method receives the PerfLocale DI container (`PerfLocale\Plugin`, itself `@api`). Beyond the container, these runtime classes carry the `@api` tag — stable across the 1.x line, safe to depend on:

-   `PerfLocale\Concurrency\Lock` — atomic critical-section guard
-   `PerfLocale\Concurrency\Breaker` — circuit breaker for external dependencies
-   `PerfLocale\Background\Dispatcher` — background-job dispatch
-   `PerfLocale\Background\AbstractJob` — base class for custom jobs
-   `PerfLocale\Background\JobState` — persistent job state
-   `PerfLocale\Background\JobLock` — the per-job lock a worker holds
-   `PerfLocale\Addon\AddonSettings` — per-addon settings storage
-   `PerfLocale\Helper` — fluent helper API for templates and addons

The addon interfaces themselves — `AddonInterface`, `HasSchema`, `HasUninstallTargets`, `HasCustomUninstall`, `HasVersionRequirement`, `HasCardInfo` — carry the same tag. The DI container exposes typed accessors (`$plugin->lang_repo()`, `$plugin->cache()`, etc.) so addons get IDE autocomplete + type safety without casting.

## On this page

-   [AddonRegistry contract validation](#contract)
-   [The DI container — what's available, how to reach it](#container)
-   [Lock — serialise concurrent work](#lock)
-   [Breaker — protect external calls](#breaker)
-   [Background jobs — dispatch + custom job class](#jobs)
-   [Helper API — the `perflocale()` fluent surface](#helper)
-   [Settings — storage, sanitisation, admin form](#settings)
-   [i18n — the modern textdomain pattern](#i18n)
-   [Version requirement — pin a minimum PerfLocale](#version-requirement)
-   [Enable/disable — per-addon on/off switch](#enable-disable)
-   [Addon-to-addon dependencies](#dependencies)
-   [Capability interfaces — `HasSchema`, `HasUninstallTargets`, `HasCardInfo`, …](#capabilities)
-   [WP-CLI commands — the operator surface](#cli)
-   [Full example addon](#full-example)
-   [API stability promise](#api-stability)

## AddonRegistry contract validation

Every call to `AddonRegistry::register()` runs seven contract checks at registration time. The first four reject the addon (the registry is the trust boundary, so production sites are protected). The last three are `WP_DEBUG`\-only nudges with zero production cost. All seven emit `_doing_it_wrong()` plus an error-log entry — none crash the site.

| Check | Mode | Behaviour |
| --- | --- | --- |
| Invalid get_id() (empty / non-string / illegal chars) | Production | Rejected |
| Late registration (after the final addon boot pass at plugins_loaded:99) | Production | Rejected |
| Bundled-ID conflict (external addon reusing a reserved id) | Production | Rejected |
| Duplicate registration (same id twice) | Production | Rejected |
| Empty get_name() | WP_DEBUG | Nudge, allowed |
| Malformed get_version() (not semver-shaped) | WP_DEBUG | Nudge, allowed |
| Non-array get_required_plugins() | WP_DEBUG | Nudge, allowed |

Rejected addons never reach the registry's internal map — subsequent `perflocale/addons/registered` output excludes them and Site Health does not surface them in the Addons Info table.

## The DI container — typed accessors

Addons receive a `PerfLocale\Plugin` instance via `boot()`. Two ways to reach a service:

```php
// 1. Typed accessor (preferred — IDE autocomplete + correct return type):
$languages = $plugin->lang_repo()->get_active();
$cache     = $plugin->cache();
$current   = $plugin->router()->get_current_language();

// 2. Service ID constant + get() (for services without a typed accessor yet):
$registry = $plugin->get( \PerfLocale\Plugin::SERVICE_ADDONS );

// 3. Magic-string get() (still works — but prefer the constant):
$slug_mgr = $plugin->get( 'slug_manager' );
```

**Available typed accessors** (each returns the named class):

| Accessor | Returns | Use for |
| --- | --- | --- |
| $plugin->settings() | Settings | Read configured options |
| $plugin->cache() | Cache\CacheManager | 3-layer cache (static → object cache → transient) |
| $plugin->router() | Router\LanguageRouter | Current language, locale detection |
| $plugin->lang_repo() | Database\Repository\LanguageRepository | Active languages, find by slug/locale |
| $plugin->group_repo() | Database\Repository\TranslationGroupRepository | Translation groups + links between posts/terms |
| $plugin->slug_manager() | Router\SlugManager | Translated-slug resolution |
| $plugin->url_converter() | Router\UrlConverter | Adding language prefixes, rewriting URLs |
| $plugin->addon_registry() | Addon\AddonRegistry | Introspect or modify the addon set |

Beyond these eight, the rest of the container (about 60 service IDs are registered in total, though the exact set depends on which features and addons are active) is reachable via `$plugin->get( '<id>' )`. Check one with `$plugin->has( '<id>' )` first — `get()` on an unregistered ID throws. To enumerate what your install actually registered:

```php
wp eval '$p = \PerfLocale\Plugin::get_instance();
$r = new ReflectionProperty( $p, "factories" );
$r->setAccessible( true );
foreach ( array_keys( $r->getValue( $p ) ) as $id ) { echo $id . "\n"; }'
```

(`factories` is a private property, so reflection is required — and because it is private, it is _not_ part of the `@api` surface. Use this for one-off inspection, never in shipped addon code.)

## Lock — serialise concurrent work

Use `PerfLocale\Concurrency\Lock` when two requests could run the same work at the same time and one of them needs to win. Backed by a raw `INSERT IGNORE` on `wp_options`, so the InnoDB unique key enforces mutual exclusion at the database level — no race window.

### Recommended pattern: `Lock::with()`

```php
use PerfLocale\Concurrency\Lock;

Lock::with( 'myaddon_remote_sync', 30, function () {
    // Critical section — only one process can be inside this closure
    // for a given lock name at a time. TTL is 30 seconds: if the holder
    // crashes, the next caller can take over after the TTL expires.
    do_the_expensive_thing();
} );
```

`Lock::with()` returns whatever your callback returns when the lock is acquired, or `null` if another holder is already inside the critical section (a callback that itself returns `null` is indistinguishable from a missed lock — return a sentinel if you need to tell them apart). The callback's exceptions are not swallowed — they propagate after the lock releases.

### Manual acquire/release (when you need finer control)

```php
use PerfLocale\Concurrency\Lock;

if ( ! Lock::acquire( 'myaddon_remote_sync', 30 ) ) {
    return; // someone else is already syncing
}

try {
    do_the_expensive_thing();
} finally {
    Lock::release( 'myaddon_remote_sync' );
}
```

**Naming convention.** Always prefix lock names with your addon’s slug to avoid collisions with PerfLocale’s own locks and other addons. The name is used verbatim as the suffix of a `wp_options` row named `perflocale_lock_<name>`, so keep it short and stick to `[a-z0-9_]` — nothing sanitises or truncates it for you.

**Re-entrancy.** Locks are tracked per request. If the current request already holds a lock, a second `acquire()` for the same name returns `false` rather than granting it again — so don’t nest two acquisitions of the same lock inside one call stack.

## Breaker — protect external calls

Use `PerfLocale\Concurrency\Breaker` around every outbound network call your addon makes. After 5 failures within 5 minutes (defaults; tunable per breaker via the `perflocale/breaker/threshold/{key}` filter), the breaker trips OPEN and subsequent `is_open()` calls return `true` instantly — saving your visitors from waiting through 30-second TCP timeouts.

```php
use PerfLocale\Concurrency\Breaker;

function myaddon_call_remote_api( string $endpoint ): ?array {
    $breaker_key = 'myaddon_remote';

    if ( Breaker::is_open( $breaker_key ) ) {
        // Don't even try. Fall through to a cached value or
        // "service unavailable" UI.
        return null;
    }

    $response = wp_remote_get( $endpoint, [ 'timeout' => 5 ] );

    if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 500 ) {
        Breaker::record_failure( $breaker_key, 'http_5xx_or_timeout' );
        return null;
    }

    Breaker::record_success( $breaker_key );

    return json_decode( wp_remote_retrieve_body( $response ), true );
}
```

The Site Health screen lists every open breaker with a one-click "Reset now" link, and recovery is automatic when the upstream comes back (the breaker transitions OPEN → HALF\_OPEN → CLOSED on the first success).

### Introspection (admin tooling, dashboards)

```php
$status = Breaker::status( 'myaddon_remote' );
// [ 'state' => 'open', 'failures' => 5, 'reason' => 'http_5xx_or_timeout',
//   'opened_at' => 1715000000, 'cooldown_remaining' => 184 ]

$all = Breaker::list_all();   // every breaker the plugin knows about

Breaker::reset( 'myaddon_remote' );  // force-close after fixing the upstream
```

## Background jobs — dispatch + custom job class

Use `PerfLocale\Background\Dispatcher` when you have work that exceeds the request budget (large imports, bulk MT, file regeneration). PerfLocale's job runner picks Action Scheduler when available, falls back to WP-Cron, and ships with watchdog + per-job locking + automatic resume on PHP-fatal crashes.

### Define a custom job

```php
namespace MyAddon\Jobs;

use PerfLocale\Background\AbstractJob;

final class CrunchTranslationsJob extends AbstractJob {

    public function get_type(): string {
        return 'myaddon_crunch_translations';
    }

    /**
     * Re-checked at worker time against the user that dispatched the job
     * (stored as `created_by`). A revoked cap mid-flight fails the job
     * cleanly rather than running unprivileged.
     */
    public function get_required_capability(): string {
        return 'manage_options';
    }

    /**
     * Threshold for the inline-vs-async decision in Auto mode — compared
     * against args_size(). Jobs with item-counts above this go to the
     * background queue automatically; below it they run inline.
     */
    public function get_default_threshold(): int {
        return 50;
    }

    /**
     * Number of "items" in this dispatch. Used by the dispatcher's Auto
     * mode against get_default_threshold() / the per-type setting in
     * Settings → Performance → Background Thresholds.
     */
    protected function args_size( array $args ): int {
        return count( (array) ( $args['object_ids'] ?? [] ) );
    }

    /**
     * The actual work. Called inline (sync path) or by the worker hook
     * (async path). MUST be re-entrant (same args → same outcome on a
     * re-run after a crash) and bounded in memory.
     *
     * Call $progress( $done, $total ) periodically — every 1 s or every
     * 50 items, whichever first — so the Jobs admin page shows live
     * counts AND so the per-job lock TTL refreshes.
     */
    public function execute( array $args, callable $progress ): array {
        $ids       = (array) ( $args['object_ids'] ?? [] );
        $total     = count( $ids );
        $processed = 0;

        foreach ( $ids as $id ) {
            $this->do_one_item( (int) $id );
            $progress( ++$processed, $total );
        }

        return [ 'processed' => $processed ];
    }
}
```

### Dispatch it from anywhere

```php
use PerfLocale\Background\Dispatcher;
use MyAddon\Jobs\CrunchTranslationsJob;

$result = Dispatcher::dispatch( new CrunchTranslationsJob(), [
    'object_ids' => [ 12, 13, 14, 15, /* … */ ],
] );

// $result['mode']    — 'sync' (ran inline) | 'async' (queued) | 'denied' (cap check or a
//                      perflocale/jobs/should_dispatch veto) | 'error' (couldn't enqueue)
// $result['job_id']  — present only when mode === 'async'; query via JobState::get($job_id)
// $result['result']  — present on a 'sync' run that succeeded
// $result['error']   — present on 'denied' / 'error', AND on a 'sync' run that threw,
//                      so check for 'error' even when mode === 'sync'
```

The dispatcher records the job in `JobState`, decides whether to run inline (under threshold) or queue, and the runtime handles batching, persistence, and resume. [Full background-jobs reference](https://perflocale.com/docs/background-jobs/) covers the threshold contract, retry policy, and watchdog semantics.

## Helper API — the `perflocale()` fluent surface

The `Helper` class is the one-line API for the most-asked questions about the current request:

```php
perflocale()->locale();             // 'fr_FR'
perflocale()->slug();               // 'fr'
perflocale()->name();               // 'French'
perflocale()->native_name();        // 'Français'
perflocale()->is_default();         // false
perflocale()->is_rtl();             // false
perflocale()->flag();               // '🇫🇷' or a flag code
perflocale()->current_language();   // full language object
perflocale()->default_language();   // default language object
perflocale()->languages();          // every active language
```

Plus static utilities for tasks addons commonly need:

```php
use PerfLocale\Helper;

Helper::format_locale_as_bcp47( 'fr_FR' );    // 'fr-FR'  (BCP 47 for HTTP headers, hreflang, JS Intl)
Helper::date_format_for_language( $lang );    // language-specific date format
Helper::time_format_for_language( $lang );    // language-specific time format
Helper::uploads_translations_dir();           // safe writable dir for generated translation files
Helper::harden_directory( $dir );             // drops .htaccess / index.php for direct-access protection
Helper::get_flag_emoji( $lang );              // flag emoji for a language object
```

## Settings — storage, sanitisation, admin form

Every `AddonInterface` implementation declares its configurable surface via `get_settings_fields()`. PerfLocale renders those fields as an auto-generated form on the addon’s own subtab under **WP Admin → PerfLocale → Settings → Addons** (the _Manage_ button on the addon’s card links straight to it), persists the values in a single non-autoloaded option (`perflocale_addon_settings`) keyed by addon ID, and gives you a one-call read API from anywhere in the codebase.

**Declare your fields.** Each entry needs at minimum a `type`. The user-editable types — `checkbox`, `text`, `textarea`, `number`, `select`, `password`, `custom` — render automatically. `password` behaves like `text` for sanitisation but renders as `<input type="password">` so API keys and tokens aren’t shoulder-readable. `custom` hands rendering and sanitisation back to your addon via per-field callbacks — see [_Custom field type_](#custom-field-type) below. Anything else (e.g. `hidden`) is left alone by the generic save handler so you can store addon-managed state without it being clobbered.

```
public function get_settings_fields(): array {
    return [
        'enable_sync' => [
            'type'        => 'checkbox',
            'label'       => __( 'Enable hourly sync', 'my-addon' ),
            'default'     => true,
            'description' => __( 'Disable while debugging upstream API issues.', 'my-addon' ),
        ],
        'api_endpoint' => [
            'type'    => 'text',
            'label'   => __( 'API endpoint', 'my-addon' ),
            'default' => 'https://api.example.com/v1',
        ],
        'cache_ttl' => [
            'type'    => 'number',
            'label'   => __( 'Cache TTL (seconds)', 'my-addon' ),
            'default' => 3600,
        ],
        'environment' => [
            'type'    => 'select',
            'label'   => __( 'Environment', 'my-addon' ),
            'default' => 'prod',
            'options' => [
                'prod'    => __( 'Production', 'my-addon' ),
                'staging' => __( 'Staging', 'my-addon' ),
            ],
        ],
        // Addon-managed state — admin form leaves this alone.
        'last_synced_at' => [
            'type'    => 'hidden',
            'default' => 0,
        ],
    ];
}
```

**Read settings from anywhere.** Use the static helper. It costs one option read per request (the option is `autoload=false`, so it's a single SELECT on first touch, then memoised statically), so it's cheap even in hot paths:

```php
use PerfLocale\Addon\AddonSettings;

// Single field with default fallback (matches `get_settings_fields()` defaults).
$enabled  = AddonSettings::get( 'my-addon', 'enable_sync', true );
$endpoint = AddonSettings::get( 'my-addon', 'api_endpoint', 'https://api.example.com/v1' );

// Full addon group as an array.
$all = AddonSettings::get_addon( 'my-addon' );

// Several keys in one call — slightly cheaper than chained get() calls
// because the in-memory cache is hit once, not N times. Per-key defaults
// optional; missing keys return null.
$cfg = AddonSettings::get_many(
    'my-addon',
    [ 'enable_sync', 'api_endpoint', 'cache_ttl' ],
    [ 'enable_sync' => true, 'cache_ttl' => 3600 ]
);
```

**Defaults auto-seed on first boot.** When the registry boots an addon for the first time (no entry yet in `perflocale_addon_settings`), it reads `get_settings_fields()` and writes every declared `default` in one `set_addon()` call. Subsequent boots are no-ops — even if the user’s explicit save matches the defaults, the entry exists and we don’t re-seed. Net effect: `AddonSettings::get( 'my-addon', 'enable_sync' )` returns the declared default on a fresh install without callers having to pass one. The seeding only writes fields that actually declare a `default` — `hidden` fields used as opaque scratch space are left alone. The [`perflocale/addon/seeded`](https://perflocale.com/docs/hooks/#perflocale-addon-seeded) action fires once after the write commits, so addons can do one-shot first-activation work (welcome email, sample-data import) from that hook.

**Observe save events.** Two actions fire inside the storage lock around every `set` / `set_addon` / `forget` that commits:

-   [`perflocale/addon/settings/before_save`](https://perflocale.com/docs/hooks/#perflocale-addon-settings-before-save) — before `update_option()`.
-   [`perflocale/addon/settings/after_save`](https://perflocale.com/docs/hooks/#perflocale-addon-settings-after-save) — after.

Both pass `$addon_id, $new_entry, $old_entry`. Neither fires on rejected writes (invalid id, over the 16 KiB cap, or lock contention). **Reentrancy:** the lock is non-reentrant — do NOT call `AddonSettings::set / set_addon / forget` from a listener or the inner write is rejected immediately and returns `false` (the same-request lock check fails fast; the 10 s is the lock's crash-recovery TTL, not a wait). For cross-addon write reactions, defer your write via `wp_schedule_single_event` so it runs outside the lock window:

```php
add_action( 'perflocale/addon/settings/after_save', function ( string $id, array $new, array $old ): void {
    if ( $id !== 'my-other-addon' ) {
        return;
    }
    // Defer the cross-addon write — running it here would deadlock.
    wp_schedule_single_event( time() + 5, 'myaddon/rebuild_cache' );
}, 10, 3 );
```

**Dedicated settings subtab per addon.** Each registered addon with user-editable `get_settings_fields()` gets its own subtab under **PerfLocale → Settings → Addons**. The _Manage_ button on the addon card on the Addons listing page links to it. The listing page itself doesn’t embed a form — settings live on their own page.

**Conditional fields (`show_if`).** Any field can declare a `show_if` spec that controls when it renders:

```
'enable_sync' => [
    'type'    => 'checkbox',
    'label'   => __( 'Enable upstream sync', 'my-addon' ),
    'default' => true,
],
'endpoint' => [
    'type'    => 'text',
    'label'   => __( 'Remote endpoint', 'my-addon' ),
    'show_if' => [ 'enable_sync' => true ],
],
'advanced_retries' => [
    'type'    => 'number',
    'label'   => __( 'Retry attempts', 'my-addon' ),
    'default' => 3,
    'show_if' => [
        'op'    => 'AND',
        'rules' => [
            [ 'enable_sync' => true ],
            [ 'mode'        => 'advanced' ],
        ],
    ],
],
```

The spec evaluator (`AddonSettings::evaluate_show_if()`) runs server-side for the initial render and an equivalent client-side evaluator (`assets/js/addon-settings-conditional.js`, enqueued automatically under the handle `perflocale-addon-settings-conditional` on the Settings page) keeps it in sync as the user toggles driver fields. Both implementations match each other’s loose-equality behaviour so `'1'` matches `true` for checkbox-style fields.

**Works outside auto-addon forms too.** The JS scans the whole document for `[data-perflocale-show-if]` elements, scoped to the nearest `<form>` ancestor for change-event delegation. Driver inputs are identified by either `data-perflocale-field-name` (canonical, what `render_form()` emits) or by their standard HTML `name` attribute (fallback — lets hand-rolled admin tabs opt into the same conditional system by just adding the `data-perflocale-show-if` attr to a row). The bundled WooCommerce settings tab uses this fallback path to drive its currency / exchange-rate conditional rows.

Two shapes are supported:

-   **Simple equality** — an associative array where keys are driver field names and values are expected values. Multiple keys are AND-ed.
-   **Nested operator** — `[ 'op' => 'AND' | 'OR', 'rules' => [ … ] ]` with each rule being either a simple-equality map or another nested spec.

Empty / missing `show_if` means the field is always visible.

**Write addon-managed state.** Use `set()` for a single field or `set_addon()` when overwriting the whole group. The admin form uses `set_addon()` for the user-editable fields and preserves anything else — including your `hidden` fields. All three writers (`set`, `set_addon`, `forget`) return `bool` — `true` on commit, `false` if the write was rejected (see [Failure modes](#settings-failure-modes) below):

```php
// Track a job's last run from inside execute(). Capture the bool —
// false here typically means another save is in flight and the lock
// timed out; a retry on the next tick usually succeeds.
$saved = AddonSettings::set( 'my-addon', 'last_synced_at', time() );
if ( ! $saved ) {
    // Defer to next tick; the value will be retried then.
}

// Bulk overwrite (rare — typically only the admin form does this).
$ok = AddonSettings::set_addon( 'my-addon', [
    'enable_sync'    => false,
    'cache_ttl'      => 600,
    'last_synced_at' => 0,
] );
if ( ! $ok ) {
    // The whole addon entry exceeds the 16 KiB per-addon cap — split
    // your state across smaller fields, or move bulk data to your own
    // option / custom table.
}
```

**Sanitisation is centralised.** If you build your own admin UI instead of using PerfLocale's auto-rendered form, call `AddonSettings::sanitize_field( $field, $raw_post_value )` per field. It applies the right WP sanitiser per type (`sanitize_text_field` / `sanitize_textarea_field`) and validates `select` values against the declared options to defend against tampered POST data.

### Custom field type — `render_callback` + `sanitize_callback`

When a single field needs a bespoke widget — a matrix, a colour picker, a file picker, an autocomplete — declare it as `'type' => 'custom'` and supply a `render_callback` (for the row markup) and a `sanitize_callback` (for the POST value). Both fit into the auto-rendered form alongside your other fields and participate in `show_if` evaluation the same way:

```
'colour' => [
    'type'              => 'custom',
    'label'             => __( 'Brand colour', 'my-addon' ),
    'default'           => '#3858e9',
    'render_callback'   => function ( $addon_id, $key, $field, $value, $all_values ) {
        printf(
            '<input type="color" name="settings[%1$s]" value="%2$s" data-perflocale-field-name="%1$s" />',
            esc_attr( $key ),
            esc_attr( $value )
        );
    },
    'sanitize_callback' => function ( $addon_id, $key, $field, $raw ) {
        // $raw is the value at $_POST['settings'][$key] (already in $raw
        // for the canonical slot); read $_POST directly for nested shapes.
        return sanitize_hex_color( is_string( $raw ) ? wp_unslash( $raw ) : '' ) ?: $field['default'];
    },
],
```

Both callbacks receive the same first three arguments — `$addon_id`, the field `$key`, and the field `$field` definition. `render_callback` additionally receives the current `$value` and the full `$all_values` array for cross-field decisions; `sanitize_callback` additionally receives the `$raw` POST value at the canonical `settings[$key]` slot. Callbacks that emit nested input shapes (a per-language matrix, a repeater) should read `$_POST` directly inside the sanitize callback. Add `data-perflocale-field-name="<key>"` to your input so it can drive `show_if` rules from other fields.

Omitting `sanitize_callback` on a `'custom'` field tells the save handler to leave the stored value alone — useful for addon-managed state that you write yourself elsewhere (own admin AJAX, REST endpoint, background job) and just want the auto-form to skip without overwriting.

### Full subtab takeover — the three settings hooks

When an addon’s settings surface is too bespoke for field-by-field rendering — the bundled WooCommerce subtab is the canonical example, with its currency matrix, exchange-rate provider conditional rows, AJAX “Sync Now” + “Create Page Translations” buttons — take the subtab over through the public hook pair rather than the auto-form:

1.  Register your subtab with [`perflocale/settings/addon_subtabs`](https://perflocale.com/docs/hooks/#perflocale-settings-addon-subtabs).
2.  Render the whole body from [`perflocale/settings/render_addon_subtab`](https://perflocale.com/docs/hooks/#perflocale-settings-render-addon-subtab) (`string $subtab, Settings $settings`), bailing early when `$subtab` isn’t yours.
3.  Collect your POST values from [`perflocale/settings/extract_addon_values`](https://perflocale.com/docs/hooks/#perflocale-settings-extract-addon-values).

You still own enqueueing your own JS / CSS assets, your own AJAX nonces, and your own POST sanitisation. The same `data-perflocale-show-if` attrs you’d use on auto-rendered rows work inside the takeover too, since the conditional-fields JS scans the whole Settings page DOM and doesn’t care whether a row came from `get_settings_fields()` or from your bespoke template.

**Note on `render_settings_subtab()`.** The bundled WooCommerce addon exposes a `public function render_settings_subtab( \PerfLocale\Settings $settings ): void`, but that method is _not_ a general extension point — `SettingsPage` routes to it from a hardcoded `match()` arm for the built-in `woocommerce` subtab only. Declaring the same method on a third-party addon has no effect; use the three hooks above.

### Settings stored in the main `perflocale_settings` option — `'storage' => 'global'`

Most addons keep their settings in the framework’s storage layer, and that’s the path everything above covers. But some bundled integrations (the WooCommerce addon, for example) keep their settings in the main `perflocale_settings` option instead — the runtime, the REST APIs, and the main settings save handler all read from there. Declaring the same fields under `get_settings_fields()` without a marker would have the framework seed a dead duplicate copy into `perflocale_addon_settings` that nothing ever read. Mark them `'storage' => 'global'` instead:

```
'wc_email_translation' => [
    'type'    => 'checkbox',
    'label'   => __( 'Send order emails in the customer language', 'my-addon' ),
    'default' => true,
    'storage' => 'global', // lives in perflocale_settings, not perflocale_addon_settings
],
```

The marker tells the framework: skip auto-seed for this field; skip auto-form rendering (your subtab takeover owns it); have `wp perflocale addon settings get` transparently read from `perflocale_settings` so operators see the live value; have `wp perflocale addon settings set` write through `Settings::update()` (so the full invalidation chain runs) instead of into addon storage; have `settings list` surface the field with its live value alongside addon-storage fields, with a `storage` column marking the source. If a value for the field already sits in addon storage from before it was marked `'storage' => 'global'`, that copy is harmless — global-storage reads go to `perflocale_settings`, so the stale row is simply never read again. Use it only when your field is genuinely a main-plugin setting — for addon-internal configuration the default storage layer is the right answer.

### Failure modes — when writes return `false`

Writers return `false` instead of throwing, so callers can branch without a try/catch. The rejection is also logged via `error_log()` when `WP_DEBUG` is on. Four cases:

-   **Invalid addon id** — `$addon_id` must match `/^[a-z0-9_-]{2,16}$/` (the canonical pattern enforced by `AddonSchemaManager::validate_addon_id()`). Empty / uppercase / too-long ids are rejected; hyphens are permitted (e.g. `contact-form-7`). Read calls (`get` / `get_addon`) treat an invalid id the same as "not stored" — they return the supplied default or an empty array, not an error.
-   **Per-addon size cap (16 KiB serialised)** — the whole `perflocale_addon_settings` option is read and deserialised in one go the first time any addon reads a setting. Capping per-addon prevents a buggy or malicious addon from growing the option without bound. If your addon needs more than 16 KiB of state, use `AddonSettings` only for the user-editable configuration and keep bulk data in your own option / custom table.
-   **Lock contention** — writes are serialised through `Lock::with('addon_settings_write', 10s, ...)` so two concurrent saves can't race on the shared option. The inner mutator re-reads the option from the database (bypassing in-memory + object caches) so it always operates on the latest-committed state. If the 10 s lock window expires — rare; the actual write is a single `update_option` call — the writer returns `false`. A retry on the next tick almost always succeeds.
-   **The admin UI** — the auto-form save handler behind `WP Admin → PerfLocale → Settings → Addons` (the `admin-post.php` action `perflocale_save_addon_settings`) consumes the bool: a successful save shows _"Settings for X saved."_ as a success notice; a rejected one redirects with `perflocale_msg=addon_save_failed` and renders an error notice explaining the size-cap / lock-contention path so the operator knows to look at the PHP error log.

## i18n — the modern textdomain pattern

`load_plugin_textdomain()` has been effectively deprecated since WordPress 4.6, and WordPress 6.7+ throws a `_doing_it_wrong` notice if it's called too early. The right pattern depends on where your addon is hosted:

-   **wp.org-hosted addons:** Do nothing. Core's just-in-time loader resolves your `.mo` file on the first `__()` call. The header `Text Domain:` in your plugin file is all you need.
-   **Off-wp.org addons:** Call `Helper::load_addon_textdomain()` from an `init`\-priority-99 hook so the user's locale is finalised before `.mo` resolution.

**Lifecycle guard:** `Helper::load_addon_textdomain()` emits `_doing_it_wrong()` when called before the `init` hook fires (the load itself still proceeds, but WP 6.7+ may strip early-loaded translations); hook your textdomain loader on `init` or later — same too-early contract as the template tags. Hook your textdomain loader on `init` (or later, e.g. `after_setup_theme` followed by `init`) to be safe.

```php
use PerfLocale\Helper;

public function boot( \PerfLocale\Plugin $plugin ): void {
    add_action( 'init', static function () {
        Helper::load_addon_textdomain(
            'my-addon',                                  // Text Domain header
            plugin_dir_path( __FILE__ ) . 'languages'    // .mo files directory
        );
    }, 99 );
}
```

The helper looks for `{domain}-{locale}.mo` in the directory you pass (matching the standard `.mo` naming) and returns `false` without warning if there's no matching file. Internally it uses Core's `load_textdomain()`, not the deprecated `load_plugin_textdomain()`.

## Version requirement — pin a minimum PerfLocale

If your addon calls an API introduced in a specific PerfLocale release, opt into the version gate so older sites get a clear admin notice instead of a fatal:

```php
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\HasVersionRequirement;

final class MyAddon implements AddonInterface, HasVersionRequirement {

    public function get_min_perflocale_version(): string {
        return '1.4.0';     // The version that introduced the API you depend on.
    }

    // ... rest of AddonInterface ...
}
```

At boot time the registry runs `version_compare( PERFLOCALE_VERSION, $required, '<' )` — if the running plugin is older, your addon is skipped (not failed; nothing is registered, no hooks fire) and the addon is listed in a red _"requires a newer PerfLocale"_ notice on the Addons admin page. The site owner gets a clear upgrade path; nothing crashes.

## Enable/disable — per-addon on/off switch

Every addon card on the Addons admin page shows an **Enable / Disable** button. Clicking it toggles the addon's presence in the `perflocale_disabled_addons` option. On the next request the registry's `boot_addons()` loop checks the list and skips disabled addons before calling `is_compatible()` or `boot()` — so a misbehaving addon can be parked without uninstalling the host plugin.

The button works for **both bundled (in-plugin) and external addons**. Disabling a bundled addon takes effect on the next request and stops its hooks from firing, so only disable one if you genuinely don't want that integration — e.g. disabling the bundled WooCommerce addon means you lose product translation, currency-per-language, order email translation, etc.

**Programmatic toggle** — if you need to disable an addon from a deployment script or a feature-flag system, the registry exposes:

```php
use PerfLocale\Addon\AddonRegistry;

// set_disabled() returns bool: true on commit, false on rejection
// (invalid addon id, or the 4 KiB disabled-list cap would be exceeded).
$ok = AddonRegistry::set_disabled( 'my-addon', true );    // disable
if ( ! $ok ) {
    // Either the addon id is malformed (regex /^[a-z0-9_-]{2,16}$/) or
    // the disabled-list option is already at its 4 KiB ceiling — both
    // log to error_log() under WP_DEBUG.
}

AddonRegistry::set_disabled( 'my-addon', false );         // re-enable
$disabled = AddonRegistry::get_disabled();                // array<string> of disabled IDs
$is_off   = AddonRegistry::is_disabled( 'my-addon' );
```

A successful `set_disabled()` automatically invalidates the `perflocale_bootable_addons` transient so the change is picked up on the very next request — you don't have to wait for the 12-hour TTL.

**Admin UI parity.** The per-card Enable / Disable button on the Addons admin page wraps the same call. On rejection it redirects with `perflocale_msg=addon_toggle_failed` and renders an error notice explaining the failure mode so the operator knows to check the PHP error log under `WP_DEBUG`.

**Capability gate.** The save and toggle handlers both require the `perflocale_manage_addons` capability. Administrators have it by default; the Translator and Editor roles do not. See the [Permissions reference](https://perflocale.com/docs/permissions/#perflocale-manage-addons).

## Addon-to-addon dependencies

If addon B needs addon A booted first, the cleanest pattern is to gate B's `is_compatible()` on the registry, and to do B's hook wiring inside the `perflocale/addons/loaded` action so the boot order is independent of registration order:

```php
use PerfLocale\Plugin;
use PerfLocale\Addon\AddonInterface;

final class AddonB implements AddonInterface {

    private const REQUIRED_ADDON = 'addon-a';

    public function is_compatible(): bool {
        // Adjacent plugin / theme check stays here, BUT defer the
        // addon-A presence check to boot() — at is_compatible() time
        // the registry hasn't necessarily finished registering A yet.
        return class_exists( 'AddonA\\Service' );
    }

    public function boot( Plugin $plugin ): void {
        // Late dependency check — runs after the addon-A boot pass
        // has either succeeded or been skipped.
        add_action( 'perflocale/addons/loaded', function () use ( $plugin ) {
            $registry = $plugin->addon_registry();

            if ( ! $registry->is_booted( self::REQUIRED_ADDON ) ) {
                // Surface the missing dependency in WP_DEBUG logs.
                if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
                    error_log( 'AddonB skipped: ' . self::REQUIRED_ADDON . ' is not booted.' );
                }
                return;
            }

            $this->wire_hooks( $plugin );
        } );
    }

    private function wire_hooks( Plugin $plugin ): void {
        // Safe to call AddonA's surface from here.
    }
}
```

Two things to know:

-   The `perflocale/addons/loaded` action fires once, after every addon's `boot()` has been invoked (or skipped). It's the canonical "all addons settled" signal.
-   If addon A also implements `HasVersionRequirement` and gets gated out, B's `is_booted()` check will return `false` — the dependency chain handles the version mismatch automatically.

## Capability interfaces — `HasSchema`, `HasUninstallTargets`, …

Addons declare optional features by implementing small marker interfaces alongside `AddonInterface`. The plugin looks for each interface on activation, uninstall, or whenever the corresponding lifecycle event fires — you don't have to register anything, just implement.

| Interface | Method | When it fires |
| --- | --- | --- |
| HasSchema | get_schema(): array | Every Migrator pass (admin_init, rest_api_init, or wp_loaded priority 1 — whichever fires first), and idempotent so repeats are free. Returns a [short_name => CREATE TABLE body] map (no wrapper); the framework adds the enforced {prefix}perflocale_addon_{id}_{short} name + charset/collate and runs each through dbDelta(). |
| HasUninstallTargets | get_uninstall_targets(): array | Plugin uninstall. Declarative list of tables, options, site_options, transient prefixes, post/user/term/comment meta keys, capabilities, and cron hooks — the central uninstaller handles deletion. |
| HasCustomUninstall | before_uninstall(PurgePlan $plan): void | Plugin uninstall, before the HasUninstallTargets sweep — so your tables and options are still live when it runs. Use only when the declarative list isn't expressive enough (e.g. for cleanup that needs custom SQL). |
| HasVersionRequirement | get_min_perflocale_version(): string | Boot. Addon is skipped (not failed) when the host plugin is older — see above. |
| HasCardInfo | get_card_info(): array | Card render on the Addons admin page. Returned array can override name, description, category, icon, requires, settings_tab. A bare get_card_info() method without the interface is ignored. The interface gives IDE autocomplete + static-analysis visibility. |

```php
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\HasSchema;
use PerfLocale\Addon\HasUninstallTargets;

final class MyAddon implements AddonInterface, HasSchema, HasUninstallTargets {

    // Short name => CREATE TABLE body (no wrapper, no prefix). The framework
    // names it {prefix}perflocale_addon_myaddon_log, adds charset/collate, dbDelta()s it.
    public function get_schema(): array {
        return [
            'log' => "
                id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
                event VARCHAR(64) NOT NULL,
                created_at DATETIME NOT NULL,
                PRIMARY KEY (id),
                KEY idx_event (event)
            ",
        ];
    }

    public function get_uninstall_targets(): array {
        // 'tables' = short names (prefix auto-applied); 'options' MUST start with
        // 'perflocale_'; 'transients' are prefixes; 'meta' is keyed by object type.
        return [
            'tables'     => [ 'log' ],
            'options'    => [ 'perflocale_myaddon_settings', 'perflocale_myaddon_last_run' ],
            'transients' => [ 'perflocale_myaddon_cache_' ],
            'meta'       => [
                'post' => [ '_perflocale_myaddon_synced_at' ],
                'user' => [ 'perflocale_myaddon_preferences' ],
            ],
            'cron_hooks' => [ 'perflocale_myaddon_sync' ],
        ];
    }

    // ... rest of AddonInterface ...
}
```

These capability interfaces are additive — implement one or many. The plugin uses `instanceof` checks, so unrelated future interfaces won't break your existing addons.

## WP-CLI commands — the operator surface

Every UI action on the Addons admin page has a matching `wp perflocale addon` subcommand so CI / deployment scripts can configure addons without screen-scraping. Same validation, same caps, same rejection paths.

| Command | Does |
| --- | --- |
| wp perflocale addon doctor | One-shot health summary — counts of booted / disabled / quarantined / version-mismatched addons with the IDs of each. The first place to look when an addon isn’t doing what you expect. |
| wp perflocale addon enable <id> | Remove the addon from the disabled list. Always succeeds for a valid id (the operation is idempotent). |
| wp perflocale addon disable <id> | Add the addon to the disabled list. Works for both bundled and external addons; the change applies on the next request. |
| wp perflocale addon settings get <addon-id> <key> [--default=<val>] [--format=scalar\|json] | Read one stored setting. Scalar output mirrors wp option get conventions (bool → true/false, arrays → JSON, null → empty). |
| wp perflocale addon settings set <addon-id> <key> <value> [--type=string\|bool\|int\|float\|json] | Write one setting, typed via --type. Returns an error if the per-entry 16 KiB cap would be exceeded. |
| wp perflocale addon settings list <addon-id> [--format=table\|json\|csv\|yaml] | Dump all stored settings for an addon as a key/value table. |
| wp perflocale addon reset-quarantine <id> | Clear a quarantined addon’s failure counter after you’ve fixed the underlying boot error. Lets it retry on the next request. |
| wp perflocale addon list [--format=…] [--all] | Table of every registered addon: id, name, bundled / booted / disabled / compatible flags, has_schema with target + stored schema versions, has_uninstall / has_custom, and whether a manifest exists. --all includes orphan manifests (addons whose class is no longer present). |
| wp perflocale addon info <id> | Full manifest + purge-plan preview for one addon — useful before running an uninstall. |
| wp perflocale addon migrate [<id>] | Run pending schema migrations for one or all addons. |
| wp perflocale addon orphans | List addons whose stored manifest references a class that’s no longer on disk — common after deleting an addon plugin without going through PerfLocale’s uninstall path. |
| wp perflocale addon errors [--clear] | Show recent addon migration / uninstall errors. --clear empties the log. |
| wp perflocale addon reset-version <id> <version> | Manually rewind a stored schema version — recovery only; the Migrator handles versions automatically in normal operation. |

**Site Health.** The Addons section in **Tools → Site Health → Info** shows the same data in a non-CLI form: active addons, quarantined addons, operator-disabled addons, version-mismatched addons, and the current byte sizes of the `perflocale_addon_settings` option (`autoload=false`) and the autoloaded `perflocale_disabled_addons` option against their 16 KiB / 4 KiB caps. Useful for support handoffs.

Beyond `perflocale_addon_quarantine` and `perflocale_addon_schema`, the _Status_ tab also runs `perflocale_eager_link_map` (autoloaded-option size + `too_large` sentinel), `perflocale_cron_schedule` (watchdog / GC / lock-cleanup hooks scheduled, with a `DISABLE_WP_CRON` + Action Scheduler hint), `perflocale_stuck_translations` (`in_progress` / `pending` rows older than 7 days), and `perflocale_orphan_rows` (`translation_links` referencing deleted posts/terms). The last two cache their `COUNT` in a 1-hour transient to keep _Tools → Site Health_ reloads cheap.

## Full example addon

This is a minimal but complete addon that demonstrates every toolkit feature: typed-accessor DI lookups, a Lock-guarded critical section, a Breaker-wrapped remote call, and a background-job dispatch. Copy it as a starting point for your own integration.

```php
<?php
/**
 * Plugin Name: MyAddon for PerfLocale
 * Description: Demonstrates the PerfLocale developer toolkit.
 * Version:     1.0.0
 * Requires Plugins: perflocale
 */

declare( strict_types=1 );

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

use PerfLocale\Plugin;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Concurrency\Lock;
use PerfLocale\Concurrency\Breaker;
use PerfLocale\Background\Dispatcher;
use PerfLocale\Background\AbstractJob;

// Register on PerfLocale's addon filter — fires once after plugin boot.
add_filter( 'perflocale/addons/registered', static function ( $addons ) {
    if ( ! interface_exists( AddonInterface::class ) ) {
        return $addons;
    }
    $instance = new MyAddon();
    $addons[ $instance->get_id() ] = $instance;
    return $addons;
}, 200 );

final class MyAddon implements AddonInterface {

    public function get_id(): string         { return 'myaddon'; }
    public function get_name(): string       { return 'My Addon'; }
    public function get_version(): string    { return '1.0.0'; }
    public function get_required_plugins(): array { return []; }
    public function is_compatible(): bool    { return true; }
    public function get_settings_fields(): array  { return []; }

    public function boot( Plugin $plugin ): void {
        // Use the DI container for repositories.
        $lang_repo = $plugin->lang_repo();
        $cache     = $plugin->cache();

        // Subscribe to a PerfLocale event.
        add_action( 'perflocale/language/added', function ( $lang ) {
            $this->on_language_added( $lang );
        } );
    }

    private function on_language_added( $lang ): void {
        // Lock-guarded critical section so two concurrent language-added
        // events don't both kick off the same sync.
        Lock::with( 'myaddon_lang_sync_' . $lang->slug, 30, function () use ( $lang ) {
            // Breaker around the remote call — five failures and we stop
            // hammering the upstream until it recovers.
            if ( Breaker::is_open( 'myaddon_remote' ) ) {
                return;
            }
            $response = wp_remote_post( 'https://api.example.com/lang', [
                'body'    => [ 'slug' => $lang->slug, 'locale' => $lang->locale ],
                'timeout' => 5,
            ] );
            if ( is_wp_error( $response ) ) {
                Breaker::record_failure( 'myaddon_remote', 'connection_error' );
                return;
            }
            Breaker::record_success( 'myaddon_remote' );
        } );

        // Heavy work goes to the job queue.
        Dispatcher::dispatch( new MyAddonSyncJob(), [ 'language_slug' => $lang->slug ] );
    }
}

final class MyAddonSyncJob extends AbstractJob {
    public function get_type(): string                { return 'myaddon_sync'; }
    public function get_required_capability(): string { return 'manage_options'; }
    public function get_default_threshold(): int      { return 100; }

    public function execute( array $args, callable $progress ): array {
        // Per-batch work — Lock + Breaker patterns from above still apply.
        // Tick at least once so the per-job lock TTL refreshes.
        $progress( 1, 1 );
        return [ 'processed' => 0 ];
    }
}
```

## API stability promise

Every class listed above is marked `@api` in PHPDoc and considered a public surface bound by semantic versioning:

-   **1.x line** — method signatures, return types, and behavioural contracts will not change. New methods may be added.
-   **Service-ID constants** (`Plugin::SERVICE_*`) are stable; the underlying string values are an implementation detail that may change in major versions.
-   **Typed accessors** (`$plugin->lang_repo()`, etc.) won't change return type within the 1.x line.
-   **Filter/action hooks** tagged `@hook perflocale/...` are similarly stable. The [Hooks Reference](https://perflocale.com/docs/hooks/) documents 200+ of them.

Anything _not_ marked `@api` (most of the `src/` tree) is an internal implementation detail and may change in any release without notice. If you find yourself reaching for an internal class from an addon, open an issue — we'll evaluate promoting it to the public surface.
