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 forrender_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 fromperflocale_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 arender_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 Nget()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 soAddonSettings::get( $id, $key )returns the documented default without callers passing one. - Schema / tables? Implement
HasSchemawithget_schema()returning[ short_name => SQL body string ]. The framework wraps each with thewp_perflocale_addon_{id}_prefix and runs dbDelta. Bumpget_schema_version()+ addmigrate_to_N()for breaking changes. - Uninstall cleanup? Implement
HasUninstallTargetswithget_uninstall_targets()listing your tables / options / meta / caps / cron hooks. The framework wipes everything on plugin uninstall. Every name must start withperflocale_(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 / forgetfrom insideperflocale/addon/settings/{before,after}_save— the lock is non-reentrant and the inner write will time out at 10s. Defer cross-addon writes viawp_schedule_single_event. - Writer returned
false? Four cases: invalid addon id (must match/^[a-z0-9_-]{2,16}$/— hyphens permitted so the bundledcontact-form-7/beaver-builder/gravity-formsids 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_thresholdfilter. - Need to be skipped on old hosts? Implement
HasVersionRequirementreturning 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 exportbundle carries every addon’sperflocale_addon_settingsentry; the importer restores them as-is, so cloning staging → prod no longer silently loses addon config. Credentials with shape*_api_key / *_token / *_key / *_secret / *_passwordare 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 guardPerfLocale\Concurrency\Breaker— circuit breaker for external dependenciesPerfLocale\Background\Dispatcher— background-job dispatchPerfLocale\Background\AbstractJob— base class for custom jobsPerfLocale\Background\JobState— persistent job statePerfLocale\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
- The DI container — what's available, how to reach it
- Lock — serialise concurrent work
- Breaker — protect external calls
- Background jobs — dispatch + custom job class
- Helper API — the
perflocale()fluent surface - Settings — storage, sanitisation, admin form
- i18n — the modern textdomain pattern
- Version requirement — pin a minimum PerfLocale
- Enable/disable — per-addon on/off switch
- Addon-to-addon dependencies
- Capability interfaces —
HasSchema,HasUninstallTargets,HasCardInfo, … - WP-CLI commands — the operator surface
- Full example addon
- API stability promise
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:
// 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, ~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 upstreamBackground 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 objectSettings — 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 default — hidden 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:
perflocale/addon/settings/before_save— beforeupdate_option().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 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 bool — true 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_idmust match/^[a-z0-9_-]{2,16}$/(the canonical pattern enforced byAddonSchemaManager::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_settingsoption 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, useAddonSettingsonly 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 singleupdate_optioncall — the writer returnsfalse. A retry on the next tick almost always succeeds. - The admin UI — the inline-form save handler at
WP Admin → PerfLocale → Addonsconsumes the bool: a successful save shows "Settings for X saved." as a success notice; a rejected one redirects withperflocale_msg=addon_save_failedand 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
.mofile on the first__()call. The headerText Domain:in your plugin file is all you need. - Off-wp.org addons: Call
Helper::load_addon_textdomain()from aninit-priority-99 hook so the user's locale is finalised before.moresolution.
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/loadedaction fires once, after every addon'sboot()has been invoked (or skipped). It's the canonical "all addons settled" signal. - If addon A also implements
HasVersionRequirementand gets gated out, B'sis_booted()check will returnfalse— 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 | Plugin 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(). |
HasUninstallTargets | get_uninstall_targets(): array | Plugin uninstall. Declarative list of options, post meta keys, user meta keys, and tables to drop — the central uninstaller handles deletion. |
HasCustomUninstall | before_uninstall(PurgePlan $plan): void | Plugin uninstall, after HasUninstallTargets sweep. 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. 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.
| 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 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 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 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.