---
title: "Addon System - Schema and Migrations - PerfLocale"
description: "Build PerfLocale addons that own tables, migrate their schema, and clean up on uninstall. Three capability interfaces plus manifest-driven purge."
canonical: "https://perflocale.com/docs/addon-system/"
source: "https://perflocale.com/docs/addon-system/"
format: "markdown"
---
# 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 `dbDelta` automatically, tracks per-addon versions, and runs incremental `migrate_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](https://perflocale.com/docs/developer-api/) for the basic `AddonInterface` registration pattern. This page layers schema + uninstall on top of that.

Companion reading: [Developer Toolkit for Addons](https://perflocale.com/docs/addon-system/developer-toolkit/) 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`, delete `active_plugins`, remove `manage_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 declare `transients: ['perflocale_a_cache_']`, `meta: { post: ['_perflocale_a_id'] }`, or `cron_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](https://perflocale.com/docs/hooks/#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](https://perflocale.com/docs/addon-system/developer-toolkit/#contract).

## Lifecycle - what happens when

```php
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
<?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
<?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.

```php
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
<?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, batched `LIMIT 1000` at a time so a site with millions of posts doesn't time out.
-   **User meta:** same pattern.
-   **Capability:** `perflocale_wfnotifr_manage` removed 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
<?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 returned `PurgeResult->custom_uninstall_error` and logged to the admin notice.
-   **The plan is read-only.** `PurgePlan` is 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 via `manifest.had_custom_uninstall` and logs a `custom_uninstall_skipped` warning 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
<?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_transcch` is read + purged.
-   **Per-blog tables:** each site has its own `{$wpdb->prefix}perflocale_addon_transcch_entries` table - the prefix changes per blog (`wp_2_`, `wp_3_`, …) so they're distinct physical tables.
-   **Per-blog options:** `perflocale_transcch_settings` is stored in each subsite's `{$wpdb->prefix}options` table, cleaned per-blog.
-   **Network site\_options:** `perflocale_transcch_network_stats` lives in `{$wpdb->base_prefix}sitemeta` and 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.](https://perflocale.com/docs/developer-api/)

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

```php
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 `dbDelta` for column additions (auto-diffs), `INSERT IGNORE` or `ON DUPLICATE KEY UPDATE` for seed rows, and check `information_schema.COLUMNS` before `ALTER TABLE ADD COLUMN` if 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()` returns `site_options` for 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 a `try/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 1000` per iteration and fire `perflocale/addon/meta_purge_batch` each 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 on `PurgeResult->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](https://perflocale.com/docs/developer-api/) - basic `AddonInterface` registration pattern.
-   [Developer Toolkit for Addons](https://perflocale.com/docs/addon-system/developer-toolkit/) — Locks, Breakers, Background jobs, the DI container's typed accessors, and the fluent Helper — the classes PerfLocale tags `@api` and holds stable for the 1.x line.
-   [Hooks Reference](https://perflocale.com/docs/hooks/) - all PerfLocale actions and filters.
-   [WP-CLI Commands](https://perflocale.com/docs/wp-cli/) - full command surface including `wp perflocale addon`.
-   [Permissions & Roles](https://perflocale.com/docs/permissions/) - 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`, and `HasCardInfo.php` each carry a documented contract, and the bundled addons under `addons/` are working implementations.
