# Wp Plugin Lifecycle

> 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.

- Skill: `vikingokft/wp-plugin-lifecycle` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vikingokft/wp-plugin-lifecycle`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vikingokft/wp-plugin-lifecycle/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vikingokft (https://skillmd.com/u/vikingokft)
- Updated: 2026-09-08
- Page: https://skillmd.com/skills/vikingokft/wp-plugin-lifecycle

---


# 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](wp-admin/includes/plugin.php)). |

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

```php
register_activation_hook( __FILE__, static function (): void {
    // 1. Requirements re-check (the bootstrap-time check may have been bypassed
    //    by direct DB activation). Bail loud if anything is missing.
    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 ) );
    }

    // 2. Seed default options — add_option respects existing values, so
    //    reactivation after a deactivate-without-uninstall preserves user
    //    preferences. NEVER use update_option here.
    add_option( 'myplugin_settings', array(
        'log_level'   => 'errors',
        'cache_ttl'   => 3600,
    ) );

    // 3. Schema migration via dbDelta. Note the explicit require_once —
    //    dbDelta is in wp-admin/includes/upgrade.php, NOT loaded by default.
    require_once ABSPATH . 'wp-admin/includes/upgrade.php';

    global $wpdb;
    $charset = $wpdb->get_charset_collate();
    // dbDelta is finicky — follow the canonical style EXACTLY:
    //   - one column per line, two spaces after column name
    //   - PRIMARY KEY on its own line at the end
    //   - lowercase types ('bigint(20)', 'datetime'), as WP itself uses
    //   - no IF NOT EXISTS (dbDelta diff-applies)
    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};" );

    // 4. Schedule recurring cron events (the schedule constant must already
    //    be registered on the 'cron_schedules' filter in your runtime code).
    if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
        wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );
    }

    // 5. Capability seeding (only if you genuinely need plugin-specific caps).
    $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](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).

```php
register_activation_hook( __FILE__, static function ( bool $network_wide = false ): void {
    if ( $network_wide ) {
        // Network activation: seed every site's per-site state.
        foreach ( get_sites( array( 'fields' => 'ids' ) ) as $site_id ) {
            switch_to_blog( $site_id );
            myplugin_setup_site();
            restore_current_blog();
        }
        // Plus any network-wide options.
        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

```php
register_deactivation_hook( __FILE__, static function (): void {
    // Clear ALL scheduled events for our hooks, regardless of $args.
    // wp_unschedule_hook (since WP 4.9) is more robust than
    // wp_clear_scheduled_hook because it doesn't require remembering
    // the exact $args that were passed at schedule time.
    wp_unschedule_hook( 'myplugin_daily_cleanup' );
    wp_unschedule_hook( 'myplugin_token_refresh' );

    // OPTIONAL: clear active-state transients that are meaningless when
    // the plugin is off. Most TTL-bearing transients can self-expire.
    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:

```php
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
<?php
// uninstall.php — at the plugin root.
//
// WP defines WP_UNINSTALL_PLUGIN before include_once'ing this file.
// Bail if it's missing — some misconfiguration is loading us directly.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

global $wpdb;

if ( is_multisite() ) {
    // For a network-wide preserve toggle, store + read it as a site option:
    //     get_site_option( 'myplugin_preserve_on_uninstall' )
    // For a per-site toggle, check INSIDE the loop after switch_to_blog().
    // Below we honor a per-site toggle so different sites can choose differently.
    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 {
    // 1. Options
    delete_option( 'myplugin_settings' );
    delete_option( 'myplugin_version' );

    // 2. Per-user meta the plugin set
    delete_metadata( 'user', 0, 'myplugin_dismissed_notice', '', true );

    // 3. CPT posts + their meta (optional — destructive)
    $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 );
    }

    // 4. Custom table
    $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_log" );

    // 5. Transients (pattern-delete, since names may include dynamic IDs)
    $wpdb->query(
        "DELETE FROM {$wpdb->options}
         WHERE option_name LIKE '\\_transient\\_myplugin\\_%'
            OR option_name LIKE '\\_transient\\_timeout\\_myplugin\\_%'"
    );

    // 6. Cron events
    wp_unschedule_hook( 'myplugin_daily_cleanup' );
    wp_unschedule_hook( 'myplugin_token_refresh' );

    // 7. Capabilities
    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](wp-admin/includes/plugin.php)). 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](wp-includes/plugin.php) 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](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

```php
// WRONG — overwrites user's existing settings on reactivation
register_activation_hook( __FILE__, function () {
    update_option( 'myplugin_settings', $defaults );
} );

// WRONG — args mismatch leaves the cron event dangling
wp_clear_scheduled_hook( 'myplugin_cleanup', array( 'mode' => 'fast' ) );
// scheduled with array( 'mode' => 'aggressive' ) earlier — never matches

// WRONG — uninstall.php using plugin classes (autoloader not running)
require __DIR__ . '/uninstall.php';
use MyPlugin\Schema;          // fatal: class not found
delete_option( Schema::OPTION_KEY );

// WRONG — register_uninstall_hook to do anything non-trivial
register_uninstall_hook( __FILE__, array( 'MyPlugin\\Plugin', 'uninstall' ) );
// callback can't be a closure or instance method, plus the plugin main
// file gets re-included at uninstall time

// WRONG — deletes user data on deactivation
register_deactivation_hook( __FILE__, function () {
    delete_option( 'myplugin_settings' );  // user re-activates -> loses preferences
} );
```

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

- Uninstall methods: [Plugin Handbook](https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/)
- `register_activation_hook` / `register_deactivation_hook` / `register_uninstall_hook`: [wp-includes/plugin.php](wp-includes/plugin.php)
- `uninstall_plugin()` (the function that includes `uninstall.php`): [wp-admin/includes/plugin.php:1302-1330](wp-admin/includes/plugin.php)
- `wp_unschedule_hook` (since 4.9): [wp-includes/cron.php](wp-includes/cron.php)
- `dbDelta`: [wp-admin/includes/upgrade.php](wp-admin/includes/upgrade.php)
- Multisite blog switching: [wp-includes/ms-blogs.php](wp-includes/ms-blogs.php)

