Claude Skill

wp-plugin-lifecycle

Designs and reviews the three lifecycle events of a WordPress

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download lonsdale201-wp-agent-skills-plugin-scaffold_wp-plugin-lifecycle-52f6020.zip · 7 KB
Part of lonsdale201/wp-agent-skills — 226 skills

Install

skills CLI npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-plugin-lifecycle
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart
Git git clone https://github.com/Lonsdale201/wp-agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole lonsdale201/wp-agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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.

Update-time migrations after plugin files are replaced are out of scope here; note that activation does not fire on an 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

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

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

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:

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

Files (wp-agent-skills)
  • references
    • uninstall-and-multisite.md 3.6 KB
      # Isolated uninstall and multisite cleanup
      
      ## Standalone uninstall pattern
      
      `uninstall.php` runs with WordPress loaded but without the plugin's main file:
      
      ```php
      <?php
      
      if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
          exit;
      }
      
      function myplugin_cleanup_site_data(): void {
          global $wpdb;
      
          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 );
          }
      
          // Controlled identifier derived only from WordPress's table prefix.
          $table = $wpdb->prefix . 'myplugin_log';
          $wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
      
          wp_unschedule_hook( 'myplugin_daily_cleanup' );
          wp_unschedule_hook( 'myplugin_token_refresh' );
      
          foreach ( array_keys( wp_roles()->roles ) as $role_slug ) {
              $role = get_role( $role_slug );
              if ( $role ) {
                  $role->remove_cap( 'manage_myplugin' );
              }
          }
      }
      ```
      
      When deleting dynamic transient names with SQL, escape `LIKE` wildcards and
      prepare the patterns. Do not copy a raw prefix into interpolated query text:
      
      ```php
      $value_pattern   = $wpdb->esc_like( '_transient_myplugin_' ) . '%';
      $timeout_pattern = $wpdb->esc_like( '_transient_timeout_myplugin_' ) . '%';
      
      $wpdb->query(
          $wpdb->prepare(
              "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
              $value_pattern,
              $timeout_pattern
          )
      );
      ```
      
      ## Preserve-data policy
      
      Read a per-site preserve toggle after switching into that site. Store a truly
      network-wide policy in a site option and read it once before looping. Make the
      choice explicit in product UI; do not silently preserve half the schema.
      
      ```php
      if ( is_multisite() ) {
          foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
              switch_to_blog( $site_id );
      
              $options = (array) get_option( 'myplugin_settings', array() );
              if ( empty( $options['preserve_data_on_uninstall'] ) ) {
                  myplugin_cleanup_site_data();
              }
      
              restore_current_blog();
          }
      
          delete_site_option( 'myplugin_network_settings' );
      } else {
          $options = (array) get_option( 'myplugin_settings', array() );
          if ( empty( $options['preserve_data_on_uninstall'] ) ) {
              myplugin_cleanup_site_data();
          }
      }
      ```
      
      Large networks need an operationally bounded deletion design; an unbounded
      site loop can time out. Document whether deletion is synchronous, resumable,
      or intentionally requires an administrator/CLI batch before removing files.
      
      ## Review failures
      
      - `update_option()` in activation overwrites saved preferences on reactivation;
        seed with `add_option()`.
      - `wp_clear_scheduled_hook( $hook, $wrong_args )` leaves other scheduled
        argument variants; use `wp_unschedule_hook( $hook )` when all belong to the
        plugin.
      - Namespaced plugin classes are unavailable unless `uninstall.php` explicitly
        loads a dependency-free file. Do not bootstrap the entire plugin to delete it.
      - `register_uninstall_hook()` requires a static callable and reloads the plugin
        file during uninstall. Prefer the isolated file for non-trivial cleanup.
      - Deactivation must not delete user data. It is reversible pause, not removal.
      - A single-site `delete_option()` does not clean other blogs in a network.
      - User tables/meta are shared across multisite; do not repeat shared-user
        deletion blindly for every site.
      
  • SKILL.md 14.5 KB
    ---
    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.
      Does not cover update-time version migrations (running upgrade routines
      when the stored version is older than the code).
      Triggers on register_activation_hook, register_deactivation_hook,
      uninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook,
      switch_to_blog.
    metadata:
      wp-skills-author: "Soczó Kristóf"
      wp-skills-contact: "mailto:lonsdale201@hotmail.com"
      wp-skills-plugin: "wordpress"
      wp-skills-plugin-version-tested: "6.5 - 7.1"
      wp-skills-wp-version-tested: "7.1"
      wp-skills-php-min: "7.4"
      wp-skills-last-updated: "2026-08-20"
    ---
    
    # 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.
    
    Update-time migrations after plugin files are replaced are out of scope here; note that activation does not fire on an 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-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; 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/>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related