| name | wp-plugin-lifecycle |
| description | Designs and reviews the three lifecycle events of a WordPress plugin — activation (one-shot setup, dbDelta, add_option seeding, cron schedule, cap seeding), deactivation (reversible cleanup, cron clear via wp_unschedule_hook, never delete user data), and uninstall.php (standalone file, WP_UNINSTALL_PLUGIN guard, no autoloader, full data removal). Multisite-aware patterns using the $network_wide / $network_deactivating callback args, plus the recommendation against register_uninstall_hook in favor of uninstall.php. Use when scaffolding a plugin or debugging ghost cron events / orphan options. Triggers on register_activation_hook, register_deactivation_hook, uninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook, switch_to_blog. |
| author | Soczó Kristóf |
| contact | mailto:lonsdale201@hotmail.com |
| plugin | wordpress |
| plugin-version-tested | 6.5 - 6.9 |
| php-min | 7.4 |
| last-updated | "2026-04-28T00:00:00.000Z" |
| source | https://github.com/Lonsdale201/wp-agent-skills/tree/8684fef5b4c33bc0cd783f9fff7770b1f7f59c57/plugin-scaffold/wp-plugin-lifecycle |
| source-license | MIT |
| license | MIT |
WordPress plugin: lifecycle (activate / deactivate / uninstall)
The three events that frame a plugin's existence on a site. Each has a different scope, different runtime context, and different non-negotiable rules. Get the contract wrong and you ship plugins that:
- Activate "successfully" but leave the site in a broken state.
- Leave behind cron events that fire forever after deactivation.
- Leave 50 orphan options + 100k orphan meta rows after uninstall.
This skill assumes the plugin already has a clean bootstrap (see wp-plugin-bootstrap). It covers ONLY what happens at the three lifecycle boundaries.
When to use this skill
Trigger when ANY of the following is true:
- Scaffolding a new plugin and writing the activation / deactivation / uninstall logic.
- Reviewing a PR that touches
register_activation_hook, register_deactivation_hook, or uninstall.php.
- Debugging "ghost cron events still firing after my plugin is deactivated", or "I deleted the plugin but options are still in
wp_options".
- Adding a "Preserve data on uninstall" toggle / a clean removal toggle for site owners.
- Adapting an existing plugin to be multisite-aware (per-site activation, network-wide uninstall).
The diff or file most likely contains: register_activation_hook, register_deactivation_hook, register_uninstall_hook (anti-pattern, see below), uninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook, wp_clear_scheduled_hook, delete_option, delete_site_option, or switch_to_blog.
The three events at a glance
| Event | Hook / file | When it fires | Runtime context |
|---|
| Activate | register_activation_hook( __FILE__, $cb ) → activate_<basename> | User clicks "Activate" in /wp-admin/plugins.php. Also re-fires on reactivation. NOT on plugin update. | Full WP loaded, user logged in, plugin's main file already loaded. Classes via autoloader available. |
| Deactivate | register_deactivation_hook( __FILE__, $cb ) → deactivate_<basename> | User clicks "Deactivate". | Full WP loaded, plugin loaded. |
| Uninstall | uninstall.php at plugin root | User clicks "Delete" on a deactivated plugin. | Full WP loaded, BUT plugin's main file NOT loaded — uninstall.php runs in isolation with only the WP API available. WP_UNINSTALL_PLUGIN constant is defined (wp-admin/includes/plugin.php:1324). |
That third row is the unintuitive one. WP includes uninstall.php at the top of uninstall_plugin() — your namespaced classes, your Plugin::instance(), your composer autoload — none of it is loaded. Only the WP global functions and the $wpdb global are available.
Activation — one-shot setup
register_activation_hook( __FILE__, static function (): void {
if ( ! function_exists( 'jet_form_builder' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
deactivate_plugins( plugin_basename( __FILE__ ) );
wp_die( esc_html( 'JetFormBuilder must be active.' ), '', array( 'back_link' => true ) );
}
add_option( 'myplugin_settings', array(
'log_level' => 'errors',
'cache_ttl' => 3600,
) );
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$charset = $wpdb->get_charset_collate();
dbDelta( "CREATE TABLE {$wpdb->prefix}myplugin_log (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
created_at datetime NOT NULL,
message text NOT NULL,
PRIMARY KEY (id),
KEY created_at (created_at)
) {$charset};" );
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
}
$editor = get_role( 'editor' );
if ( $editor && ! $editor->has_cap( 'manage_myplugin' ) ) {
$editor->add_cap( 'manage_myplugin' );
}
} );
Rules for the activation callback:
- Run the requirements check again. The plugin file might have been activated through
activate_plugin() programmatically, bypassing the wp-admin UI's pre-checks. Belt and suspenders.
- Use
add_option, NOT update_option for default seeding. update_option overwrites existing values, destroying user preferences if the plugin is reactivated.
- Always
require_once 'wp-admin/includes/upgrade.php' before dbDelta() — the file is not auto-loaded outside the admin context.
- Don't register hooks (
add_action, add_filter) here. Activation is one-shot; runtime hooks belong in plugins_loaded.
- Don't perform expensive work synchronously. A long-running activation hook that blocks the request shows up as "site is taking too long to respond" in the admin. Schedule a one-shot cron event with
wp_schedule_single_event instead.
Activation in multisite
WP passes $network_wide as the first argument to your activation hook callback (wp-admin/includes/plugin.php, do_action( "activate_{$plugin}", $network_wide )). It's true if the user clicked "Network Activate", false (or unset on single-site) otherwise. Use this — don't reconstruct it from is_network_admin() or is_plugin_active_for_network(), both of which are less reliable in WP-CLI and during the activation event itself (the sitewide active option hasn't been written yet at the moment the hook fires).
register_activation_hook( __FILE__, static function ( bool $network_wide = false ): void {
if ( $network_wide ) {
foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
switch_to_blog( $site_id );
myplugin_setup_site();
restore_current_blog();
}
add_site_option( 'myplugin_network_settings', myplugin_network_defaults() );
} else {
myplugin_setup_site();
}
} );
The same pattern applies to register_deactivation_hook, which receives $network_deactivating.
Deactivation — reversible cleanup
register_deactivation_hook( __FILE__, static function (): void {
wp_unschedule_hook( 'myplugin_daily_cleanup' );
wp_unschedule_hook( 'myplugin_token_refresh' );
delete_transient( 'myplugin_api_status' );
} );
The hard rule: deactivation is REVERSIBLE. The user clicked "Deactivate", not "Delete". They might activate again tomorrow and expect their settings, custom tables, post meta, and capabilities to still be intact.
So the deactivate callback does:
- Clear cron events (otherwise WP keeps firing them; the hook has no listener but the cron table grows ghost entries).
- Clear active-state transients ("API is reachable", "license is valid this hour", etc.).
- Maybe clear flush rewrite rules if the plugin registered CPTs / custom rewrites.
It does NOT do:
- Delete options.
- Delete custom tables.
- Delete CPT posts or post meta.
- Remove capabilities. (Optional, gray area — see below.)
Cron clearing in multisite
Cron is per-blog in multisite — each site has its own scheduled events. wp_unschedule_hook only affects the current blog. The deactivation callback receives $network_deactivating as its first argument; use it to decide whether to loop:
register_deactivation_hook( __FILE__, static function ( bool $network_deactivating = false ): void {
if ( $network_deactivating ) {
foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
switch_to_blog( $site_id );
wp_unschedule_hook( 'myplugin_daily_cleanup' );
restore_current_blog();
}
} else {
wp_unschedule_hook( 'myplugin_daily_cleanup' );
}
} );
Uninstall — full removal via uninstall.php
<?php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
global $wpdb;
if ( is_multisite() ) {
foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
switch_to_blog( $site_id );
$opts = (array) get_option( 'myplugin_settings', array() );
if ( empty( $opts['preserve_data_on_uninstall'] ) ) {
myplugin_cleanup_site_data( $wpdb );
}
restore_current_blog();
}
delete_site_option( 'myplugin_network_settings' );
} else {
$opts = (array) get_option( 'myplugin_settings', array() );
if ( empty( $opts['preserve_data_on_uninstall'] ) ) {
myplugin_cleanup_site_data( $wpdb );
}
}
function myplugin_cleanup_site_data( $wpdb ): void {
delete_option( 'myplugin_settings' );
delete_option( 'myplugin_version' );
delete_metadata( 'user', 0, 'myplugin_dismissed_notice', '', true );
$post_ids = get_posts( array(
'post_type' => 'myplugin_log',
'posts_per_page' => -1,
'fields' => 'ids',
'post_status' => 'any',
) );
foreach ( $post_ids as $post_id ) {
wp_delete_post( $post_id, true );
}
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_log" );
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '\\_transient\\_myplugin\\_%'
OR option_name LIKE '\\_transient\\_timeout\\_myplugin\\_%'"
);
wp_unschedule_hook( 'myplugin_daily_cleanup' );
wp_unschedule_hook( 'myplugin_token_refresh' );
foreach ( wp_roles()->roles as $role_slug => $role_data ) {
$role = get_role( $role_slug );
if ( $role ) {
$role->remove_cap( 'manage_myplugin' );
}
}
}
Constraints uninstall.php has to live with:
- Plugin classes are NOT autoloaded. The composer / spl_autoload_register in your bootstrap file is not running here. Don't
use MyPlugin\Schema; or MyPlugin\Plugin::instance(). Either inline the meta key strings, or require your Schema.php constants file manually.
WP_UNINSTALL_PLUGIN is your guard. WP defines it (wp-admin/includes/plugin.php:1324). If it's not defined, bail — something's loading the file directly.
- Be multisite-aware. Single-site
delete_option doesn't reach other sites. Wrap per-site cleanup in a get_sites() loop, and use delete_site_option for any network-level options.
- Honor a
preserve_data_on_uninstall toggle if you offer one. Some users delete + reinstall to fix issues and want data preserved.
- The "wp_options" table cleanup for transients uses LIKE pattern matching with escaped underscores —
\\_transient\\_myplugin\\_%. The escape (\\_) prevents _ from being a single-char wildcard.
register_uninstall_hook — explicitly avoid
WP itself recommends uninstall.php over register_uninstall_hook (wp-includes/plugin.php docblock for register_uninstall_hook):
"If the plugin can not be written without running code within the plugin, then the plugin should create a file named 'uninstall.php' in the base plugin folder. This file will be called, if it exists, during the uninstallation process."
Reasons:
register_uninstall_hook requires the callback to be a static function or function name (not a closure, not an instance method) — already a code-smell constraint.
- It also requires the plugin's main file to be
include-ed at uninstall time, which means your bootstrap code runs during uninstall — fragile when classes might fail to autoload, dependencies might be inactive, etc.
uninstall.php runs in isolation with only WP available, which is exactly what you want for a destructive cleanup.
Critical rules
- Activation is one-shot setup, NOT runtime configuration. No
add_action here.
- Deactivation is REVERSIBLE. Clear cron + active-state transients. Nothing destructive.
- Uninstall is DESTRUCTIVE. Clear everything the plugin owns, in
uninstall.php, multisite-aware.
uninstall.php runs without your classes. Use raw WP functions and inline strings (or manually require a constants file).
wp_unschedule_hook($hook) over wp_clear_scheduled_hook($hook, $args) — args-mismatch means orphaned events. The former clears all events for a hook regardless of args (since WP 4.9, wp-includes/cron.php).
add_option for activation seeding, never update_option — preserves existing user preferences across reactivation.
require_once 'wp-admin/includes/upgrade.php' before any dbDelta() call.
- Multisite cron is per-blog; multisite options are per-site OR network-wide. Use
delete_site_option for network-level data, loop sites for per-site cleanup.
- Offer a
preserve_data_on_uninstall toggle. Some users reinstall; uninstall ≠ "I want to lose everything".
Common mistakes
register_activation_hook( __FILE__, function () {
update_option( 'myplugin_settings', $defaults );
} );
wp_clear_scheduled_hook( 'myplugin_cleanup', array( 'mode' => 'fast' ) );
require __DIR__ . '/uninstall.php';
use MyPlugin\Schema;
delete_option( Schema::OPTION_KEY );
register_uninstall_hook( __FILE__, array( 'MyPlugin\\Plugin', 'uninstall' ) );
register_deactivation_hook( __FILE__, function () {
delete_option( 'myplugin_settings' );
} );
Cross-references
- Run
wp-plugin-bootstrap first — it covers the main plugin file (header, constants, autoload, requirements check at activation entry).
- Run
wp-security-audit on the activation handler — it's a write endpoint with admin context.
- Run
wp-i18n-audit if the lifecycle handlers emit translated strings (admin notices, wp_die messages).
What this skill does NOT cover
- Custom cron interval registration (
cron_schedules filter), Action Scheduler integration — adjacent topic, separate skill (wp-plugin-cron, planned).
- Database schema migrations beyond the initial
dbDelta — versioned migrations need their own pattern (track schema version in an option, run pending migrations on plugin update via upgrader_process_complete).
- WP-CLI
wp plugin activate / wp plugin deactivate semantics — same hooks fire, but the multisite detection (is_network_admin()) is different.
- Theme uninstall — themes don't have a
uninstall.php equivalent; theme cleanup is generally less mechanized.
References