Addon System - Schema, Migrations, Uninstall
Three opt-in capability interfaces let addons own DB tables, run versioned migrations, and clean up every trace of themselves when the user uninstalls PerfLocale.
This guide is for developers who want to extend PerfLocale with a first-class addon that owns its own database tables, settings, post/user meta, capabilities, or cron hooks. By implementing the three opt-in interfaces described below, your addon gets:
- Managed schema - PerfLocale's Migrator runs your tables through
dbDeltaautomatically, tracks per-addon versions, and runs incrementalmigrate_to()steps on every plugin-code bump. - Strict namespace isolation - table names are auto-prefixed
{$wpdb->prefix}perflocale_addon_{addon_id}_{short_name}. Short names are pattern-validated so a malicious or buggy addon cannot escape into core tables. - Declarative uninstall - publish a manifest of what should be cleaned up (tables, options, meta keys, capabilities, cron hooks), and PerfLocale handles the actual deletion - even when your plugin's files have already been removed from disk.
- Custom cleanup callback - for the cases a declarative list can't express (e.g. ActionScheduler jobs, remote webhooks, cache-group flushes), implement
before_uninstall(). - Failure isolation - one broken migration doesn't block the rest of the plugin or other addons. Errors are logged to an admin notice.
- Manifest-driven orphan safety - the manifest is what gets iterated at uninstall time, so cleanup works cleanly even if an admin deletes your plugin before deleting PerfLocale.
Prerequisite reading: Developer API for the basic AddonInterface registration pattern. This page layers schema + uninstall on top of that.
Companion reading: Developer Toolkit for Addons documents the runtime primitives addons can call into — concurrency locks, circuit breakers, background-job dispatch, the DI container's typed accessors, and the fluent perflocale() Helper. Those classes carry an @api PHPDoc tag and are semver-bound for the 1.x line; the rest of src/ is internal and may change in any release.
The three capability interfaces
All three are in the PerfLocale\Addon namespace. They are strictly opt-in and strictly additive - an existing addon that implements only AddonInterface continues to work unchanged.
| Interface | Add when you need… |
|---|---|
HasSchema | One or more custom database tables with versioned migrations. |
HasUninstallTargets | To declare what should be purged on uninstall - tables, options, site_options, transient prefixes, meta keys (post/user/term/comment), capabilities, cron hooks. |
HasCustomUninstall | To run a before_uninstall($plan) callback for cleanup that can't be declared up front (ActionScheduler cancel, remote webhook disconnect, custom log-file delete). |
HasVersionRequirement | To pin a minimum PerfLocale version when your addon calls an API introduced in a specific release. Older hosts get a clear admin notice instead of a fatal — see the Developer Toolkit. |
HasCardInfo | To control how the addon’s card renders on the WP Admin → PerfLocale → Addons page (override category, icon, description, requires-line, settings deep-link). Replaces the legacy duck-typed get_card_info() probe with a typed interface that IDE autocomplete + static analysis can see. |
You can implement any subset. An addon that just needs a table can implement HasSchema and HasUninstallTargets. An addon that only needs a custom cleanup callback can implement HasCustomUninstall alone.
Naming rules (strict, enforced twice)
The rules below are enforced at two points: once at the input layer (AddonManifestWriter::normalize()), and again at the execution layer (AddonUninstaller::validate_hard_prefixes()) as defense-in-depth. If a name fails either check, the purge fails closed - nothing is dropped or deleted.
| Thing | Rule | Why |
|---|---|---|
| addon id | /^[a-z0-9_-]{2,16}$/ | Lowercase ASCII plus hyphen, 2–16 chars. Blocks homoglyph attacks, null bytes, and name-too-long DoS. Hyphens are accepted so the bundled contact-form-7, beaver-builder, and gravity-forms addon ids match. Used as the manifest option suffix and the table-name component (table identifiers are stripped to [A-Za-z0-9_] before use, so a hyphenated id like cf-7 appears as cf7 in the table name — always read the real name via AddonSchemaManager::table_name(), never build it by hand). |
| table short_name | /^[a-z0-9_]{1,16}$/ | 1–16 chars. Names are inserted into DROP TABLE IF EXISTS `{name}` - the pattern rejects backticks, semicolons, and any SQL metachar that could escape the backtick-quoted identifier. |
| full table name | {$wpdb->prefix}perflocale_addon_{addon_id}_{short_name} | Capped at MySQL's 64-char identifier limit even with multisite prefixes like wp_123456_. The 2–16-char addon-id and 1–16-char short-name budgets keep the full name inside the limit by construction (60 chars worst-case with a wp_123456_ prefix) — there is no separate runtime length check, and apply_schema() does not throw for length. |
| option / site_option | must start with perflocale_ | Prevents the uninstaller from deleting siteurl, active_plugins, or any other WP-core option via a crafted manifest. |
| capability | must start with perflocale_ | Prevents removing manage_options, edit_posts, or any other WP-core capability from a role. |
| transient prefix | must start with perflocale_ | Prevents expiring transients owned by unrelated plugins. |
| meta key | must start with perflocale_ or _perflocale_ | Invalid keys are skipped with a warning (soft fail) - purge continues on the valid ones. |
| cron hook | must start with perflocale_ | Prevents unscheduling unrelated plugins' cron jobs. |
Hard prefixes vs soft prefixes - the trust boundary
Two kinds of prefix enforcement live in the table above:
- Hard prefixes (tables, options, site_options, capabilities) throw the moment a manifest declares a target outside the per-addon namespace. These are the categories where a misnamed target could touch WordPress core (drop
wp_users, deleteactive_plugins, removemanage_options) so the uninstaller fails closed with nothing deleted. - Soft prefixes (transient prefixes, post/user/term/comment meta keys, cron hooks) enforce the
perflocale_namespace but not the per-addon sub-namespace. That means addon B can declaretransients: ['perflocale_a_cache_'],meta: { post: ['_perflocale_a_id'] }, orcron_hooks: ['perflocale_a_sync']and wipe addon A's state during B's uninstall.
This is deliberate. The addon-author trust model matches WordPress's own plugin trust model: an admin who installs an addon trusts that addon's code with full plugin scope. Hard prefixes guard the boundary outside PerfLocale (no plausible reason an addon should ever DROP a WP-core table); soft prefixes inside the perflocale_ namespace are a coordination space, not a security boundary. If you ship a third-party addon that needs to defend against another addon's uninstall manifest, use addon-private sub-prefixes (perflocale_yourtag_yourkey_) so a sibling's broader pattern is less likely to subsume yours, and keep critical state in a hard-prefix category (option / table) where the per-addon namespace is enforced.
Developer-mode nudge. To help catch accidental sub-namespace omissions during development, AddonManifestWriter::refresh() fires a _doing_it_wrong notice (one per offending entry) when a soft-prefix target doesn't start with perflocale_<addon_id>_ (or _perflocale_<addon_id>_ for meta keys). The notice is gated on WP_DEBUG so production users never see it. If your addon intentionally claims a broader namespace (e.g. a coordinator addon cleaning up after sibling addons), suppress the nudge via the perflocale/addon/manifest/check_soft_prefix_namespacing filter.
For the full list of register-time checks — the four production rejections (invalid id, late registration, bundled-ID conflict, duplicate registration) and the three WP_DEBUG-only nudges (empty get_name(), malformed get_version(), non-array get_required_plugins()) — see AddonRegistry contract validation.
Lifecycle - what happens when
Addon loaded (any hook priority) →
PerfLocale's Migrator iterates get('addon_registry')->get_addons()
on admin_init / rest_api_init / wp_loaded
if is_compatible() → continue; else skip
if HasSchema:
AddonSchemaManager::apply_schema() - dbDelta, idempotent
for v = stored+1..get_schema_version():
migrate_to(v) - your code runs per-step
persist stored_version = v - so a mid-sequence failure
resumes from v+1 next time
if HasUninstallTargets (or HasCustomUninstall):
AddonManifestWriter::refresh() - writes only when checksum changed
Admin deletes the PerfLocale plugin from the WordPress admin with
"Delete data on uninstall" enabled → uninstall.php iterates each
addon manifest → AddonUninstaller::purge($addon_id, $addon) →
1. gate: read perflocale_settings.delete_data_on_uninstall
2. filter: perflocale/addon/delete_data_on_uninstall (per-addon override)
3. validate_hard_prefixes() - throws on any namespace violation
4. call $addon->before_uninstall($plan) - if HasCustomUninstall
5. DROP TABLE IF EXISTS (each declared table)
6. delete_option / delete_site_option (each declared option)
7. DELETE transient + _transient_timeout rows (per prefix)
8. DELETE FROM {postmeta|usermeta|termmeta|commentmeta}
WHERE meta_key IN (…) LIMIT 1000 - batched
9. remove_cap on every role (each declared capability)
10. wp_unschedule_hook (each declared cron hook)
11. delete manifest option + stored_version entry
12. wp_cache_flush_group() for affected cache groups
Example 1 - A bookmarks addon (schema + table + versioned migration)
Goal: store a list of bookmarks in a custom table. The addon owns one table, ships with two schema versions, and deletes its data on uninstall.
Plugin bootstrap file
<?php
/**
* Plugin Name: PerfLocale Bookmarks
* Requires Plugins: perflocale
* Version: 1.0.0
*/
if ( ! defined( 'ABSPATH' ) ) exit;
add_filter( 'perflocale/addons/registered', static function ( $addons ) {
if ( ! is_array( $addons ) ) return $addons;
if ( ! interface_exists( \PerfLocale\Addon\AddonInterface::class ) ) return $addons;
require_once __DIR__ . '/class-bookmarks-addon.php';
$instance = new \PerfLocaleExample\Bookmarks\BookmarksAddon();
$addons[ $instance->get_id() ] = $instance;
return $addons;
}, 200 );
Any filter priority works — PerfLocale restores its bundled addons in code after all filters have run, and only entries that reuse a bundled addon's ID are ever overwritten. Just pick an ID that doesn't collide with a bundled one.
The addon class
<?php
namespace PerfLocaleExample\Bookmarks;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\AddonSchemaManager;
use PerfLocale\Addon\HasSchema;
use PerfLocale\Addon\HasUninstallTargets;
use PerfLocale\Plugin;
final class BookmarksAddon implements AddonInterface, HasSchema, HasUninstallTargets {
public const ADDON_ID = 'bookmarks';
public function get_id(): string { return self::ADDON_ID; }
public function get_name(): string { return 'PerfLocale Bookmarks'; }
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 boot( Plugin $plugin ): void {}
public function get_settings_fields(): array { return []; }
// ─── HasSchema ──────────────────────────────────────────────────────
public function get_schema(): array {
return [
// Short name "bookmarks" becomes:
// {$wpdb->prefix}perflocale_addon_bookmarks_bookmarks
'bookmarks' => '
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
post_id BIGINT UNSIGNED DEFAULT NULL,
url TEXT NOT NULL,
label VARCHAR(128) NOT NULL DEFAULT "",
pinned TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY post_id (post_id)
',
];
}
public function get_schema_version(): int {
return 2;
}
public function migrate_to( int $target_version ): bool {
switch ( $target_version ) {
case 1:
// Initial schema created by apply_schema() above. Nothing to do.
return true;
case 2:
// v2 added the `pinned` column to get_schema(). dbDelta will
// apply the ALTER on the next Migrator pass. Here we just
// backfill any NULL rows to the default.
global $wpdb;
$table = AddonSchemaManager::table_name( self::ADDON_ID, 'bookmarks' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query( "UPDATE `{$table}` SET pinned = 0 WHERE pinned IS NULL" );
return true;
}
return true;
}
// ─── HasUninstallTargets ────────────────────────────────────────────
public function get_uninstall_targets(): array {
return [
'tables' => [ 'bookmarks' ], // short names only
'options' => [ 'perflocale_bookmarks_settings' ],
'site_options' => [],
'transients' => [],
'meta' => [],
'capabilities' => [],
'cron_hooks' => [],
];
}
}
Using the table elsewhere
Use AddonSchemaManager::table_name() to get the full prefixed table name - never hardcode it, because the prefix varies across multisite subsites.
use PerfLocale\Addon\AddonSchemaManager;
function bookmarks_add( int $user_id, string $url, string $label ): int {
global $wpdb;
$table = AddonSchemaManager::table_name( 'bookmarks', 'bookmarks' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->insert( $table, [
'user_id' => $user_id,
'url' => $url,
'label' => $label,
'pinned' => 0,
'created_at' => gmdate( 'Y-m-d H:i:s' ),
], [ '%d', '%s', '%s', '%d', '%s' ] );
return (int) $wpdb->insert_id;
}
What happens on uninstall: PerfLocale runs DROP TABLE IF EXISTS on the full table name, and delete_option on perflocale_bookmarks_settings. Both actions are gated by the site admin's delete_data_on_uninstall setting - when OFF, the table is preserved so a later reinstall resumes cleanly.
Example 2 - Post-meta flags + cron (meta, transients, capabilities, scheduled hooks)
Goal: a translation-activity notifier addon that stores per-post flags in meta, schedules an hourly digest cron, caches recent activity in transients, and grants a custom capability. No tables.
<?php
namespace MyTheme\TranslationNotifier;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\HasUninstallTargets;
use PerfLocale\Plugin;
final class TranslationNotifierAddon implements AddonInterface, HasUninstallTargets {
public function get_id(): string { return 'wfnotifr'; } // 8 chars, underscore-safe
public function get_name(): string { return 'Translation Notifier'; }
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 {
add_action( 'perflocale/translation/created', [ $this, 'on_translation_created' ], 10, 4 );
add_action( 'perflocale_wfnotifr_hourly', [ $this, 'send_digest' ] );
// Schedule the recurring cron once
if ( ! wp_next_scheduled( 'perflocale_wfnotifr_hourly' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', 'perflocale_wfnotifr_hourly' );
}
// Register the custom capability on administrator role
add_action( 'init', [ $this, 'ensure_cap' ] );
}
public function ensure_cap(): void {
$role = get_role( 'administrator' );
if ( $role && ! $role->has_cap( 'perflocale_wfnotifr_manage' ) ) {
$role->add_cap( 'perflocale_wfnotifr_manage' );
}
}
public function on_translation_created( int $new_id, string $type, string $target_slug, int $source_id ): void {
if ( $type !== 'post' ) {
return; // This addon only tracks post translations.
}
// Track the latest activity on the new translation itself
update_post_meta( $new_id, '_perflocale_wfnotifr_last_status', 'created' );
update_post_meta( $new_id, '_perflocale_wfnotifr_last_change', time() );
// Track per-user notification preference
$user_id = get_current_user_id();
if ( $user_id ) {
update_user_meta( $user_id, 'perflocale_wfnotifr_last_seen', time() );
}
// Cache recent changes so the digest can render fast
$recent = (array) get_transient( 'perflocale_wfnotifr_recent' );
$recent[] = [ 'post' => $new_id, 'source' => $source_id, 'lang' => $target_slug, 'ts' => time() ];
set_transient( 'perflocale_wfnotifr_recent', array_slice( $recent, -50 ), HOUR_IN_SECONDS );
}
public function send_digest(): void {
$recent = (array) get_transient( 'perflocale_wfnotifr_recent' );
if ( empty( $recent ) ) {
return;
}
// …assemble & email the digest here…
delete_transient( 'perflocale_wfnotifr_recent' );
}
// ─── HasUninstallTargets ────────────────────────────────────────────
public function get_uninstall_targets(): array {
return [
'tables' => [],
'options' => [],
'site_options' => [],
'transients' => [ 'perflocale_wfnotifr_' ], // prefix match
'meta' => [
'post' => [
'_perflocale_wfnotifr_last_status',
'_perflocale_wfnotifr_last_change',
],
'user' => [ 'perflocale_wfnotifr_last_seen' ],
],
'capabilities' => [ 'perflocale_wfnotifr_manage' ],
'cron_hooks' => [ 'perflocale_wfnotifr_hourly' ],
];
}
}
What gets purged
When the site admin deletes PerfLocale from the Plugins screen with “Delete data on uninstall” enabled:
- Transients: every row matching
_transient_perflocale_wfnotifr_*or_transient_timeout_perflocale_wfnotifr_*deleted via a single DELETE statement per prefix. - Post meta: both
_perflocale_wfnotifr_*keys removed from every post, batchedLIMIT 1000at a time so a site with millions of posts doesn't time out. - User meta: same pattern.
- Capability:
perflocale_wfnotifr_manageremoved from every role that had it. - Cron:
wp_unschedule_hook( 'perflocale_wfnotifr_hourly' )clears every scheduled instance, including args variants.
Cache-group flushes (wp_cache_flush_group( 'post_meta' ), etc.) fire after deletion so same-request reads don't return stale values.
Example 3 - Custom cleanup callback (ActionScheduler + remote webhook)
Goal: an external-API sync addon that queues ActionScheduler jobs for slow outbound requests and registers a webhook endpoint with a remote service. Neither of those can be expressed in a declarative target list - both need runtime code at uninstall time.
<?php
namespace MyAgency\ApiSync;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\HasCustomUninstall;
use PerfLocale\Addon\HasUninstallTargets;
use PerfLocale\Addon\PurgePlan;
use PerfLocale\Plugin;
final class ApiSyncAddon implements AddonInterface, HasUninstallTargets, HasCustomUninstall {
public function get_id(): string { return 'apisync'; }
public function get_name(): string { return 'Translation API Sync'; }
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 boot( Plugin $plugin ): void { /* hooks */ }
public function get_settings_fields(): array { return []; }
// Declarative targets still apply - custom cleanup runs BEFORE these
public function get_uninstall_targets(): array {
return [
'tables' => [],
'options' => [
'perflocale_apisync_webhook_id',
'perflocale_apisync_api_key',
],
'site_options' => [],
'transients' => [ 'perflocale_apisync_' ],
'meta' => [],
'capabilities' => [],
'cron_hooks' => [],
];
}
// ─── HasCustomUninstall ─────────────────────────────────────────────
public function before_uninstall( PurgePlan $plan ): void {
// 1. Cancel any pending ActionScheduler jobs we queued
if ( function_exists( 'as_unschedule_all_actions' ) ) {
as_unschedule_all_actions( 'perflocale_apisync_outbound' );
as_unschedule_all_actions( 'perflocale_apisync_retry' );
}
// 2. Tell the remote API we're going away so it stops pushing
// webhook events to this site. Fire-and-forget - we don't want
// to block uninstall on a slow remote API.
$webhook_id = get_option( 'perflocale_apisync_webhook_id' );
$api_key = get_option( 'perflocale_apisync_api_key' );
if ( $webhook_id && $api_key ) {
wp_remote_post( "https://api.example.com/webhooks/{$webhook_id}/disconnect", [
'timeout' => 5,
'blocking' => false, // fire-and-forget
'headers' => [ 'Authorization' => 'Bearer ' . $api_key ],
'body' => wp_json_encode( [
'site' => home_url(),
'pending_rows' => $plan->estimated_rows,
] ),
] );
}
// 3. Delete a custom log file
$log = WP_CONTENT_DIR . '/uploads/apisync.log';
if ( file_exists( $log ) ) {
wp_delete_file( $log );
}
}
}
Safety guarantees
- Exceptions are caught. If
before_uninstall()throws, the declarative purge still runs. The exception message is recorded on the returnedPurgeResult->custom_uninstall_errorand logged to the admin notice. - The plan is read-only.
PurgePlanis a readonly value object - you can inspect$plan->tables,$plan->estimated_rows, etc., but you cannot modify the plan to influence what gets deleted. - Runs BEFORE declarative purge. So your tables/options are still live when your callback runs - you can query them if you need to assemble data for the remote call.
- Orphan safety. If the admin deletes your plugin first, and THEN uninstalls PerfLocale, your
before_uninstall()can't run (the class is gone). PerfLocale detects this viamanifest.had_custom_uninstalland logs acustom_uninstall_skippedwarning on the admin notice reminding the admin to manually clean up.
Example 4 - Multisite-safe translation cache (per-blog state)
All state in the addon system is per-blog on multisite. Each subsite maintains its own perflocale_addon_schema_versions option and its own perflocale_addon_manifest_{id} options. If your addon spans sites, you need to iterate blogs yourself.
<?php
namespace MyAgency\TransCache;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\AddonSchemaManager;
use PerfLocale\Addon\HasSchema;
use PerfLocale\Addon\HasUninstallTargets;
use PerfLocale\Addon\HasCustomUninstall;
use PerfLocale\Addon\PurgePlan;
use PerfLocale\Plugin;
final class TransCacheAddon implements AddonInterface, HasSchema, HasUninstallTargets, HasCustomUninstall {
public const ADDON_ID = 'transcch';
public function get_id(): string { return self::ADDON_ID; }
public function get_name(): string { return 'Translation Cache'; }
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 boot( Plugin $plugin ): void {}
public function get_settings_fields(): array { return []; }
public function get_schema(): array {
return [
'entries' => '
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_hash CHAR(40) NOT NULL,
source_lang VARCHAR(8) NOT NULL,
target_lang VARCHAR(8) NOT NULL,
payload LONGTEXT NOT NULL,
created_at DATETIME NOT NULL,
hit_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY triple (source_hash, source_lang, target_lang),
KEY created_at (created_at)
',
];
}
public function get_schema_version(): int { return 1; }
public function migrate_to( int $target_version ): bool { return true; }
public function get_uninstall_targets(): array {
return [
'tables' => [ 'entries' ],
'options' => [ 'perflocale_transcch_settings' ],
'site_options' => [ 'perflocale_transcch_network_stats' ],
'transients' => [ 'perflocale_transcch_' ],
'meta' => [],
'capabilities' => [],
'cron_hooks' => [],
];
}
public function before_uninstall( PurgePlan $plan ): void {
// Our table stores cache entries that include uploaded-file references.
// Clean those up before the declarative purge drops the table.
global $wpdb;
$table = AddonSchemaManager::table_name( self::ADDON_ID, 'entries' );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$refs = $wpdb->get_col( "SELECT payload FROM `{$table}` WHERE payload LIKE '%\"file_ref\"%'" );
foreach ( $refs as $raw ) {
$data = json_decode( $raw, true );
if ( ! empty( $data['file_ref'] ) && file_exists( $data['file_ref'] ) ) {
wp_delete_file( $data['file_ref'] );
}
}
}
}
Multisite behavior
PerfLocale's uninstall.php iterates get_sites() on multisite and calls SiteCleanup::purge_current_site() — passing each site's own perflocale_should_delete_data() decision — inside a switch_to_blog() / restore_current_blog() block per site. Inside each blog scope:
- Per-blog manifests: each site's
perflocale_addon_manifest_transcchis read + purged. - Per-blog tables: each site has its own
{$wpdb->prefix}perflocale_addon_transcch_entriestable - the prefix changes per blog (wp_2_,wp_3_, …) so they're distinct physical tables. - Per-blog options:
perflocale_transcch_settingsis stored in each subsite's{$wpdb->prefix}optionstable, cleaned per-blog. - Network site_options:
perflocale_transcch_network_statslives in{$wpdb->base_prefix}sitemetaand is purged exactly once (when uninstall processes blog 1 first).
Because the purge runs inside switch_to_blog(), your before_uninstall() sees the current blog's table - NOT the main blog's. A single addon uninstall on blog N only touches blog N's data. Cross-blog isolation is guaranteed and tested.
The “delete data on uninstall” setting - the only purge trigger
Addon data purge happens only when the site admin deletes PerfLocale from the Plugins screen AND has the “Delete all data on uninstall” setting enabled. There is no admin-UI button and no WP-CLI uninstall subcommand - both were intentionally omitted to keep the trigger surface narrow. This is safer:
- No accidental clicks that wipe user-generated content.
- No AJAX surface that could be abused.
- The uninstall path is the one WordPress itself drives via
uninstall.php, which is well-understood and hard to mis-fire.
Every purge is gated by the plugin-level setting perflocale_settings.delete_data_on_uninstall. When OFF (the default), AddonUninstaller::purge() short-circuits - nothing is dropped, the manifest is left intact, a skipped_by_filter entry is recorded on the returned PurgeResult. This makes reinstall “just work” with no data loss.
Override per-addon with the perflocale/addon/delete_data_on_uninstall filter. Use this to force-preserve (or force-purge) a specific addon's data regardless of the plugin-level setting:
add_filter( 'perflocale/addon/delete_data_on_uninstall', function ( $delete, $addon_id, $plan ) {
// Always preserve this addon's data - it stores user-generated content
if ( $addon_id === 'bookmarks' ) {
return false;
}
return $delete;
}, 10, 3 );The filter receives the final bool after the plugin-level gate has been evaluated. Return falsy values (false, 0, null, '') to preserve; truthy values (true, 1, any non-empty string) to delete.
WP-CLI commands
Mostly-inspection CLI surface. list, info, orphans, doctor, and errors (without --clear) are read-only; migrate, reset-version, enable, disable, and reset-quarantine write. There is no uninstall subcommand by design; the only way to purge addon data is by deleting the PerfLocale plugin with the delete-data setting enabled.
| Command | Purpose |
|---|---|
wp perflocale addon list | Table of all registered addons: id, name, bundled / booted / disabled / compatible flags, target + stored schema versions, which capability interfaces are implemented, and whether a manifest exists. |
wp perflocale addon info <id> | Show the full manifest + live PurgePlan preview (counts only - no DB mutation). |
wp perflocale addon orphans | List addon_ids that still have a manifest but no live registered class (the plugin was removed before PerfLocale was uninstalled). |
wp perflocale addon migrate [<id>] | Force the Migrator to run (optionally for a single addon). Useful after fixing a migration bug. |
wp perflocale addon errors | Tail the migration/uninstall error log. Use --clear to empty it. |
wp perflocale addon reset-version <id> <version> | Set the stored schema version for one addon to a specific value (both args are required). Setting it below the addon’s target makes the next Migrator pass re-run every step in between. Confirms before writing unless --yes is passed. |
Hooks
| Hook | Type | Args | Description |
|---|---|---|---|
perflocale/addon/before_migrate | Action | AddonInterface $addon, int $from, int $to | Fires before each migrate_to() step. |
perflocale/addon/migrated | Action | AddonInterface $addon, int $version | Fires after a successful migrate_to() step. |
perflocale/addon/migration_failed | Action | AddonInterface $addon, int $version, \Throwable $e | Fires when a migration step throws or returns false. |
perflocale/addon/manifest_written | Action | string $addon_id, array $manifest | Fires after a manifest refresh actually writes (checksum-gated). |
perflocale/addon/before_uninstall | Action | string $addon_id, PurgePlan $plan | Fires before any data is touched during purge. |
perflocale/addon/uninstalled | Action | string $addon_id, PurgeResult $result | Fires after a purge completes (success or with soft errors). |
perflocale/addon/meta_purge_batch | Action | string $type, int $batch_size, int $total_so_far | Fires after each LIMIT 1000 meta-delete batch during purge. |
perflocale/addon/delete_data_on_uninstall | Filter | bool $delete, string $addon_id, PurgePlan $plan | Per-addon override of the plugin-level delete-data setting. |
Production-readiness checklist
Before shipping an addon that owns schema + uninstall targets, verify:
- Migrations are idempotent. MySQL DDL auto-commits - partial failures can't be rolled back. Use
dbDeltafor column additions (auto-diffs),INSERT IGNOREorON DUPLICATE KEY UPDATEfor seed rows, and checkinformation_schema.COLUMNSbeforeALTER TABLE ADD COLUMNif you can't let dbDelta handle it. - Every target is prefixed correctly. Run
wp perflocale addon info <id>after your addon boots and verify the manifest contains exactly what you expect. - Your custom uninstall callback is exception-safe. Exceptions are caught by PerfLocale, but for clean operation your callback should handle its own network/IO errors gracefully.
- You don't rely on the addon class existing during uninstall. An admin can remove your plugin before uninstalling PerfLocale - the manifest carries enough info for declarative purge, but any cleanup that depends on your runtime code will be skipped with a warning.
- Multisite: test the cross-blog case. If your addon exposes a network-admin UI, make sure
get_uninstall_targets()returnssite_optionsfor any network-level options. - Performance: batch your own reads. PerfLocale's built-in purge is already batched (LIMIT 1000 per meta type), but any additional work inside
before_uninstall()needs to be batched by you if it iterates large datasets.
What the system does for you
These are properties of the code paths described above, not of your addon — they hold as soon as your addon implements the interfaces:
- Resumable migrations. The stored version is persisted after each successful
migrate_to()step, so a mid-sequence failure resumes from the next step rather than replaying from 1. - Fault isolation.
apply_schema()and each migration step run inside atry/catch; a broken addon halts at its last-good version without affecting sibling addons or the rest of the plugin. - Fail-closed purge.
validate_hard_prefixes()throws before anything is deleted if a manifest declares a table, option, site_option, or capability outside the addon’s namespace — nothing is dropped on a namespace violation. - Batched meta deletes. Meta purges run
LIMIT 1000per iteration and fireperflocale/addon/meta_purge_batcheach round, so a site with millions of rows doesn’t time out in a single statement. - Exception-safe custom uninstall. If
before_uninstall()throws, the declarative purge still runs and the message lands onPurgeResult->custom_uninstall_error. - Per-blog isolation on multisite. Every purge runs inside
switch_to_blog(), against that blog’s own manifest, options, and prefixed tables.
What it does not do for you: validating that your own migration SQL is idempotent, or that your manifest lists everything your addon actually wrote. Use wp perflocale addon info <id> to inspect the manifest PerfLocale will act on.
Further reading
- Developer API - basic
AddonInterfaceregistration pattern. - Developer Toolkit for Addons — Locks, Breakers, Background jobs, the DI container's typed accessors, and the fluent Helper — the classes PerfLocale tags
@apiand holds stable for the 1.x line. - Hooks Reference - all PerfLocale actions and filters.
- WP-CLI Commands - full command surface including
wp perflocale addon. - Permissions & Roles - capability conventions your addon can extend.
- In the plugin: the interfaces themselves are the reference —
src/Addon/HasSchema.php,HasUninstallTargets.php,HasCustomUninstall.php,HasVersionRequirement.php, andHasCardInfo.phpeach carry a documented contract, and the bundled addons underaddons/are working implementations.