Developer Toolkit for Addons

Every internal primitive PerfLocale uses to ship a reliable multilingual plugin is part of the public API surface that addons can reach for. Locks, breakers, background jobs, 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() and PerfLocale renders, sanitises, and saves them for you. Reach for render_settings_subtab() 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' so the framework skips auto-seeding a dead duplicate.
  • Bespoke field shape (colour picker, matrix, repeater)? Use 'type' => 'custom' 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 ] (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 ) 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 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() + add migrate_to_N() for breaking changes.
  • Uninstall cleanup? Implement HasUninstallTargets 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 will time out at 10s. 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 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 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 (FORMAT_VERSION 2+). The wp perflocale export bundle carries every addon’s perflocale_addon_settings entry; the importer restores them as-is, so cloning staging → prod no longer silently loses addon config. Credentials with shape *_api_key / *_token / *_key / *_secret / *_password are redacted on export.

Your addon's boot( Plugin $plugin ) method receives the full PerfLocale DI container. Beyond the container, six classes form the @api surface — 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\Helper — fluent helper API for templates and addons

These are PHPDoc-marked @api. 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

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.

CheckModeBehaviour
Invalid get_id() (empty / non-string / illegal chars)ProductionRejected
Late registration (after the final addon boot pass at plugins_loaded:99)ProductionRejected
Bundled-ID conflict (external addon reusing a reserved id)ProductionRejected
Duplicate registration (same id twice)ProductionRejected
Empty get_name()WP_DEBUGNudge, allowed
Malformed get_version() (not semver-shaped)WP_DEBUGNudge, allowed
Non-array get_required_plugins()WP_DEBUGNudge, 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:

// 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):

AccessorReturnsUse for
$plugin->settings()SettingsRead configured options
$plugin->cache()Cache\CacheManager3-layer cache (static → object cache → transient)
$plugin->router()Router\LanguageRouterCurrent language, locale detection
$plugin->lang_repo()Database\Repository\LanguageRepositoryActive languages, find by slug/locale
$plugin->group_repo()Database\Repository\TranslationGroupRepositoryTranslation groups + links between posts/terms
$plugin->slug_manager()Router\SlugManagerTranslated-slug resolution
$plugin->url_converter()Router\UrlConverterAdding language prefixes, rewriting URLs
$plugin->addon_registry()Addon\AddonRegistryIntrospect or modify the addon set

Beyond these eight, ~44 more services are reachable via $plugin->get( '<id>' ). Run wp eval 'foreach ( get_object_vars( \PerfLocale\Plugin::get_instance() )["factories"] as $id => $f ) echo $id . "\n";' to enumerate them on your install.

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()

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)

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. Lock names accept [a-z0-9_]; longer-than-64-char names are truncated.

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.

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)

$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

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

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 failed) | 'error'
// $result['job_id']  — present only when mode === 'async'; query via JobState::get($job_id)
// $result['result']  — present on a successful 'sync' run; $result['error'] on 'error' / 'denied'

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

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:

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 directly on the WP Admin → PerfLocale → Addons page (one collapsible <details> per card), persists the values in a single 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 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 hits one autoloaded option per request (deserialised once, memoised statically), so it's cheap even in hot paths:

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 defaulthidden fields used as opaque scratch space are left alone. The 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:

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 will time out at 10 seconds and return false. For cross-addon write reactions, defer your write via wp_schedule_single_event so it runs outside the lock window:

add_action( 'perflocale/addon/settings/after_save', function ( string $id, array $new, array $old ): void {
    if ( $id !== 'translation-memory' ) {
        return;
    }
    // Defer the cross-addon write — running it here would deadlock.
    wp_schedule_single_event( time() + 5, 'myaddon/rebuild_tm_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 (perflocale-addon-settings-conditional.js, enqueued automatically 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 legacy 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 booltrue on commit, false if the write was rejected (see Failure modes below):

// 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 — render_settings_subtab()

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 — declare a public function render_settings_subtab( \PerfLocale\Settings $settings ): void on your addon class. SettingsPage detects the method and delegates the whole subtab body to it instead of auto-rendering get_settings_fields(). You still own enqueueing your own JS / CSS assets, your own AJAX nonces, and your own POST sanitisation — expose the sanitiser via a public static method (e.g. MyAddon::sanitize_subtab_post()) that returns the array SettingsPage should merge into perflocale_settings. 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.

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 render_settings_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 refuse with a pointer to wp option patch update perflocale_settings <key> <value>; have settings list surface the field with its live value alongside addon-storage fields, with a storage column marking the source. The first time the framework boots after a field is moved to 'storage' => 'global', a one-off cleanup also strips any dead copy that earlier versions seeded into perflocale_addon_settings. 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 autoloaded, so every request pays its deserialise cost. 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 inline-form save handler at WP Admin → PerfLocale → Addons 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() and returns false if called before the init hook fires — 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.

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:

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

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.

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:

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.

InterfaceMethodWhen it fires
HasSchemaget_schema(): arrayPlugin activation. 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().
HasUninstallTargetsget_uninstall_targets(): arrayPlugin uninstall. Declarative list of options, post meta keys, user meta keys, and tables to drop — the central uninstaller handles deletion.
HasCustomUninstallbefore_uninstall(PurgePlan $plan): voidPlugin uninstall, after HasUninstallTargets sweep. Use only when the declarative list isn't expressive enough (e.g. for cleanup that needs custom SQL).
HasVersionRequirementget_min_perflocale_version(): stringBoot. Addon is skipped (not failed) when the host plugin is older — see above.
HasCardInfoget_card_info(): arrayCard render on the Addons admin page. Returned array can override name, description, category, icon, requires, settings_tab. Prefer this typed form over the legacy duck-typed get_card_info() method probe — same data shape, with IDE autocomplete + static-analysis visibility.
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.

CommandDoes
wp perflocale addon doctorOne-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 with version + manifest status. --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 orphansList 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 both the perflocale_addon_settings and perflocale_disabled_addons autoloaded options against their 16 KiB / 4 KiB caps. Useful for support handoffs.

Beyond perflocale_addon_quarantine and perflocale_addon_schema, the Status tab now 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
/**
 * 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 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.