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.
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). |
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 only, 2–16 chars. Blocks homoglyph attacks, null bytes, and name-too-long DoS. Used as the manifest option suffix and the table-name component. |
| 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_. If the construction would exceed 64 chars, apply_schema() throws. |
| 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. |
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 );
Priority 200 is important - it ensures your entry isn't overwritten by PerfLocale's “restore bundled addons” pass at priority 99.
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-workflow 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\WorkflowNotifier;
use PerfLocale\Addon\AddonInterface;
use PerfLocale\Addon\HasUninstallTargets;
use PerfLocale\Plugin;
final class WorkflowNotifierAddon implements AddonInterface, HasUninstallTargets {
public function get_id(): string { return 'wfnotifr'; } // 8 chars, underscore-safe
public function get_name(): string { return 'Workflow 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/workflow/status_changed', [ $this, 'on_status_changed' ], 10, 3 );
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_status_changed( int $post_id, string $old, string $new ): void {
// Track status on the post itself
update_post_meta( $post_id, '_perflocale_wfnotifr_last_status', $new );
update_post_meta( $post_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' => $post_id, 'old' => $old, 'new' => $new, '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 perflocale_uninstall_site() 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
Inspection-only CLI surface - every subcommand is read-only except migrate and reset-version. 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, schema version, capabilities implemented. |
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> |
Forget the stored schema version for one addon. Next Migrator pass will re-run every migration step from 1 to current. |
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.
Testing - what PerfLocale guarantees
The addon system ships with 524 assertions across 10 end-to-end suites, all run on each release against 3 site types (single-site, multisite subdirectory, multisite subdomain). A non-exhaustive list of what's verified:
- Full migrate → purge → migrate cycle produces the exact same end state as a fresh install (checksum match).
- Multi-addon coexistence with no cross-contamination (tested with addon A and addon B both owning tables + meta).
- Fault isolation: a broken
migrate_to()halts that addon at the last-good version without affecting sibling addons. - Concurrency: two parallel PHP processes both running
migrate()converge to the same stored version. - Attack surface: 16 malformed addon IDs (null bytes, newlines, homoglyphs, overlong) all rejected by
validate_addon_id(); TOCTOU plan-mutate-purge attack onwp_posts+siteurl+administratorcapability all blocked. - Performance: 25,000-row post-meta purge completes in under 3 seconds with peak memory delta under 32 MB.
- Real
uninstall.phpinvocation withWP_UNINSTALL_PLUGINdefined (as WordPress itself fires when a plugin is deleted from the admin).
If your addon adheres to the interfaces documented on this page, it inherits all of these guarantees without further effort on your part.
Further reading
- Developer API - basic
AddonInterfaceregistration pattern. - 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 repo:
docs/addon-system/README.mdanddocs/addon-system/example-addon/ship a minimal working reference plugin that implements all three capability interfaces.