# 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. Does not cover update-time version migrations; use wp-plugin-update-migrations when the stored version is older than code. Triggers on register_activation_hook, register_deactivation_hook, uninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook, switch_to_blog.

- Skill: `lonsdale201/wp-plugin-lifecycle` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add lonsdale201/wp-plugin-lifecycle`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/wp-plugin-lifecycle/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/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.

For update-time migrations after plugin files are replaced, use `wp-plugin-update-migrations`. Activation does not fire on ordinary plugin update.

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

```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`, `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`

Place `uninstall.php` at the plugin root, guard it with
`WP_UNINSTALL_PLUGIN`, and assume the plugin bootstrap/autoloader did not run.
Use raw WordPress functions or deliberately require a dependency-free constants
file. Delete every owned option, meta key, transient, cron event, capability,
post/object, upload, and custom table unless an explicit preserve-data policy
says otherwise.

On multisite, distinguish per-site data from network options and shared users.
Loop sites for per-site cleanup and call `delete_site_option()` for network
state. Prefer `uninstall.php` to `register_uninstall_hook()` so uninstall does
not need to load the complete plugin while its dependencies may be inactive.

Read `references/uninstall-and-multisite.md` before implementing destructive
cleanup; it contains the isolated-file pattern and failure cases.

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

Use `references/uninstall-and-multisite.md` to review reactivation overwrites,
cron-argument mismatches, unavailable plugin classes, non-trivial registered
uninstall callbacks, and destructive deactivation.

## Cross-references

- Run **`wp-plugin-bootstrap`** first — it covers the main plugin file (header, constants, autoload, requirements check at activation entry).
- Run **`wp-plugin-update-migrations`** for stored schema/data version upgrades after plugin updates.
- 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/data migrations beyond the initial `dbDelta` — versioned update migrations need their own pattern. Use `wp-plugin-update-migrations`; do not rely only on `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

- Isolated uninstall and multisite examples:
  `references/uninstall-and-multisite.md`.
- 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`
- `uninstall_plugin()` (the function that includes `uninstall.php`): `wp-admin/includes/plugin.php:1302-1330`
- `wp_unschedule_hook` (since 4.9): `wp-includes/cron.php`
- `dbDelta`: `wp-admin/includes/upgrade.php`
- Multisite blog switching: `wp-includes/ms-blogs.php`
- Official documentation: <https://developer.wordpress.org/reference/functions/register_activation_hook/>
- Official documentation: <https://developer.wordpress.org/reference/functions/register_deactivation_hook/>
- Official documentation: <https://developer.wordpress.org/reference/functions/wp_unschedule_hook/>
- Official documentation: <https://developer.wordpress.org/reference/functions/dbDelta/>

