{"slug":"wp-plugin-lifecycle","title":"wp-plugin-lifecycle","summary":"Designs and reviews the three lifecycle events of a WordPress","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-09-16T14:52:04.18158Z","repo":{"url":"https://github.com/Lonsdale201/wp-agent-skills","stars":22,"forks":2,"license":"MIT","updatedAt":"2026-09-21T19:53:59Z"},"bodyHtml":"<hr>\n<h2>name: wp-plugin-lifecycle\ndescription: Designs and reviews the three lifecycle events of a WordPress\nplugin — activation (one-shot setup, dbDelta, add_option seeding, cron\nschedule, cap seeding), deactivation (reversible cleanup, cron clear\nvia wp_unschedule_hook, never delete user data), and uninstall.php\n(standalone file, WP_UNINSTALL_PLUGIN guard, no autoloader, full\ndata removal). Multisite-aware patterns using the $network_wide /\n$network_deactivating callback args, plus the recommendation\nagainst register_uninstall_hook in favor of uninstall.php. Use when\nscaffolding a plugin or debugging ghost cron events / orphan options.\nDoes not cover update-time version migrations (running upgrade routines\nwhen the stored version is older than the code).\nTriggers on register_activation_hook, register_deactivation_hook,\nuninstall.php, WP_UNINSTALL_PLUGIN, dbDelta, wp_unschedule_hook,\nswitch_to_blog.\nmetadata:\nwp-skills-author: \"Soczó Kristóf\"\nwp-skills-contact: \"mailto:lonsdale201@hotmail.com\"\nwp-skills-plugin: \"wordpress\"\nwp-skills-plugin-version-tested: \"6.5 - 7.1\"\nwp-skills-wp-version-tested: \"7.1\"\nwp-skills-php-min: \"7.4\"\nwp-skills-last-updated: \"2026-08-20\"</h2>\n<h1>WordPress plugin: lifecycle (activate / deactivate / uninstall)</h1>\n<p>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:</p>\n<ul>\n<li>Activate \"successfully\" but leave the site in a broken state.</li>\n<li>Leave behind cron events that fire forever after deactivation.</li>\n<li>Leave 50 orphan options + 100k orphan meta rows after uninstall.</li>\n</ul>\n<p>This skill assumes the plugin already has a clean bootstrap (see <code>wp-plugin-bootstrap</code>). It covers ONLY what happens at the three lifecycle boundaries.</p>\n<p>Update-time migrations after plugin files are replaced are out of scope here; note that activation does not fire on an ordinary plugin update.</p>\n<h2>When to use this skill</h2>\n<p>Trigger when ANY of the following is true:</p>\n<ul>\n<li>Scaffolding a new plugin and writing the activation / deactivation / uninstall logic.</li>\n<li>Reviewing a PR that touches <code>register_activation_hook</code>, <code>register_deactivation_hook</code>, or <code>uninstall.php</code>.</li>\n<li>Debugging \"ghost cron events still firing after my plugin is deactivated\", or \"I deleted the plugin but options are still in <code>wp_options</code>\".</li>\n<li>Adding a \"Preserve data on uninstall\" toggle / a clean removal toggle for site owners.</li>\n<li>Adapting an existing plugin to be multisite-aware (per-site activation, network-wide uninstall).</li>\n</ul>\n<p>The diff or file most likely contains: <code>register_activation_hook</code>, <code>register_deactivation_hook</code>, <code>register_uninstall_hook</code> (anti-pattern, see below), <code>uninstall.php</code>, <code>WP_UNINSTALL_PLUGIN</code>, <code>dbDelta</code>, <code>wp_unschedule_hook</code>, <code>wp_clear_scheduled_hook</code>, <code>delete_option</code>, <code>delete_site_option</code>, or <code>switch_to_blog</code>.</p>\n<h2>The three events at a glance</h2>\n<table>\n<thead>\n<tr>\n<th>Event</th>\n<th>Hook / file</th>\n<th>When it fires</th>\n<th>Runtime context</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><strong>Activate</strong></td>\n<td><code>register_activation_hook( __FILE__, $cb )</code> → <code>activate_&lt;basename&gt;</code></td>\n<td>User clicks \"Activate\" in <code>/wp-admin/plugins.php</code>. Also re-fires on reactivation. NOT on plugin update.</td>\n<td>Full WP loaded, user logged in, plugin's main file already loaded. Classes via autoloader available.</td>\n</tr>\n<tr>\n<td><strong>Deactivate</strong></td>\n<td><code>register_deactivation_hook( __FILE__, $cb )</code> → <code>deactivate_&lt;basename&gt;</code></td>\n<td>User clicks \"Deactivate\".</td>\n<td>Full WP loaded, plugin loaded.</td>\n</tr>\n<tr>\n<td><strong>Uninstall</strong></td>\n<td><code>uninstall.php</code> at plugin root</td>\n<td>User clicks \"Delete\" on a deactivated plugin.</td>\n<td>Full WP loaded, BUT plugin's main file NOT loaded — <code>uninstall.php</code> runs in isolation with only the WP API available. <code>WP_UNINSTALL_PLUGIN</code> constant is defined (<code>wp-admin/includes/plugin.php:1324</code>).</td>\n</tr>\n</tbody>\n</table>\n<p>That third row is the unintuitive one. WP includes <code>uninstall.php</code> at the top of <code>uninstall_plugin()</code> — your namespaced classes, your <code>Plugin::instance()</code>, your composer autoload — none of it is loaded. Only the WP global functions and the <code>$wpdb</code> global are available.</p>\n<h2>Activation — one-shot setup</h2>\n<pre><code>register_activation_hook( __FILE__, static function (): void {\n    // 1. Requirements re-check (the bootstrap-time check may have been bypassed\n    //    by direct DB activation). Bail loud if anything is missing.\n    if ( ! function_exists( 'jet_form_builder' ) ) {\n        require_once ABSPATH . 'wp-admin/includes/plugin.php';\n        deactivate_plugins( plugin_basename( __FILE__ ) );\n        wp_die( esc_html( 'JetFormBuilder must be active.' ), '', array( 'back_link' =&gt; true ) );\n    }\n\n    // 2. Seed default options — add_option respects existing values, so\n    //    reactivation after a deactivate-without-uninstall preserves user\n    //    preferences. NEVER use update_option here.\n    add_option( 'myplugin_settings', array(\n        'log_level'   =&gt; 'errors',\n        'cache_ttl'   =&gt; 3600,\n    ) );\n\n    // 3. Schema migration via dbDelta. Note the explicit require_once —\n    //    dbDelta is in wp-admin/includes/upgrade.php, NOT loaded by default.\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n\n    global $wpdb;\n    $charset = $wpdb-&gt;get_charset_collate();\n    // dbDelta is finicky — follow the canonical style EXACTLY:\n    //   - one column per line, two spaces after column name\n    //   - PRIMARY KEY on its own line at the end\n    //   - lowercase types ('bigint(20)', 'datetime'), as WP itself uses\n    //   - no IF NOT EXISTS (dbDelta diff-applies)\n    dbDelta( \"CREATE TABLE {$wpdb-&gt;prefix}myplugin_log (\n        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n        created_at datetime NOT NULL,\n        message text NOT NULL,\n        PRIMARY KEY  (id),\n        KEY created_at (created_at)\n    ) {$charset};\" );\n\n    // 4. Schedule recurring cron events (the schedule constant must already\n    //    be registered on the 'cron_schedules' filter in your runtime code).\n    if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {\n        wp_schedule_event( time() + DAY_IN_SECONDS, 'daily', 'myplugin_daily_cleanup' );\n    }\n\n    // 5. Capability seeding (only if you genuinely need plugin-specific caps).\n    $editor = get_role( 'editor' );\n    if ( $editor &amp;&amp; ! $editor-&gt;has_cap( 'manage_myplugin' ) ) {\n        $editor-&gt;add_cap( 'manage_myplugin' );\n    }\n} );\n</code></pre>\n<p>Rules for the activation callback:</p>\n<ul>\n<li><strong>Run the requirements check again.</strong> The plugin file might have been activated through <code>activate_plugin()</code> programmatically, bypassing the wp-admin UI's pre-checks. Belt and suspenders.</li>\n<li><strong>Use <code>add_option</code>, NOT <code>update_option</code></strong> for default seeding. <code>update_option</code> overwrites existing values, destroying user preferences if the plugin is reactivated.</li>\n<li><strong>Always <code>require_once 'wp-admin/includes/upgrade.php'</code> before <code>dbDelta()</code></strong> — the file is not auto-loaded outside the admin context.</li>\n<li><strong>Don't register hooks</strong> (<code>add_action</code>, <code>add_filter</code>) here. Activation is one-shot; runtime hooks belong in <code>plugins_loaded</code>.</li>\n<li><strong>Don't perform expensive work synchronously.</strong> 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 <code>wp_schedule_single_event</code> instead.</li>\n</ul>\n<h3>Activation in multisite</h3>\n<p>WP passes <code>$network_wide</code> as the <strong>first argument</strong> to your activation hook callback (<code>wp-admin/includes/plugin.php</code>, <code>do_action( \"activate_{$plugin}\", $network_wide )</code>). It's <code>true</code> if the user clicked \"Network Activate\", <code>false</code> (or unset on single-site) otherwise. Use this — don't reconstruct it from <code>is_network_admin()</code> or <code>is_plugin_active_for_network()</code>, 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).</p>\n<pre><code>register_activation_hook( __FILE__, static function ( bool $network_wide = false ): void {\n    if ( $network_wide ) {\n        // Network activation: seed every site's per-site state.\n        foreach ( get_sites( array( 'fields' =&gt; 'ids' ) ) as $site_id ) {\n            switch_to_blog( $site_id );\n            myplugin_setup_site();\n            restore_current_blog();\n        }\n        // Plus any network-wide options.\n        add_site_option( 'myplugin_network_settings', myplugin_network_defaults() );\n    } else {\n        myplugin_setup_site();\n    }\n} );\n</code></pre>\n<p>The same pattern applies to <code>register_deactivation_hook</code>, which receives <code>$network_deactivating</code>.</p>\n<h2>Deactivation — reversible cleanup</h2>\n<pre><code>register_deactivation_hook( __FILE__, static function (): void {\n    // Clear ALL scheduled events for our hooks, regardless of $args.\n    // wp_unschedule_hook (since WP 4.9) is more robust than\n    // wp_clear_scheduled_hook because it doesn't require remembering\n    // the exact $args that were passed at schedule time.\n    wp_unschedule_hook( 'myplugin_daily_cleanup' );\n    wp_unschedule_hook( 'myplugin_token_refresh' );\n\n    // OPTIONAL: clear active-state transients that are meaningless when\n    // the plugin is off. Most TTL-bearing transients can self-expire.\n    delete_transient( 'myplugin_api_status' );\n} );\n</code></pre>\n<p>The hard rule: <strong>deactivation is REVERSIBLE.</strong> 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.</p>\n<p>So the deactivate callback does:</p>\n<ul>\n<li>Clear cron events (otherwise WP keeps firing them; the hook has no listener but the cron table grows ghost entries).</li>\n<li>Clear active-state transients (\"API is reachable\", \"license is valid this hour\", etc.).</li>\n<li>Maybe clear flush rewrite rules if the plugin registered CPTs / custom rewrites.</li>\n</ul>\n<p>It does NOT do:</p>\n<ul>\n<li>Delete options.</li>\n<li>Delete custom tables.</li>\n<li>Delete CPT posts or post meta.</li>\n<li>Remove capabilities. (Optional, gray area — see below.)</li>\n</ul>\n<h3>Cron clearing in multisite</h3>\n<p>Cron is <strong>per-blog</strong> in multisite — each site has its own scheduled events. <code>wp_unschedule_hook</code> only affects the current blog. The deactivation callback receives <code>$network_deactivating</code> as its first argument; use it to decide whether to loop:</p>\n<pre><code>register_deactivation_hook( __FILE__, static function ( bool $network_deactivating = false ): void {\n    if ( $network_deactivating ) {\n        foreach ( get_sites( array( 'fields' =&gt; 'ids' ) ) as $site_id ) {\n            switch_to_blog( $site_id );\n            wp_unschedule_hook( 'myplugin_daily_cleanup' );\n            restore_current_blog();\n        }\n    } else {\n        wp_unschedule_hook( 'myplugin_daily_cleanup' );\n    }\n} );\n</code></pre>\n<h2>Uninstall — full removal via <code>uninstall.php</code></h2>\n<p>Place <code>uninstall.php</code> at the plugin root, guard it with\n<code>WP_UNINSTALL_PLUGIN</code>, and assume the plugin bootstrap/autoloader did not run.\nUse raw WordPress functions or deliberately require a dependency-free constants\nfile. Delete every owned option, meta key, transient, cron event, capability,\npost/object, upload, and custom table unless an explicit preserve-data policy\nsays otherwise.</p>\n<p>On multisite, distinguish per-site data from network options and shared users.\nLoop sites for per-site cleanup and call <code>delete_site_option()</code> for network\nstate. Prefer <code>uninstall.php</code> to <code>register_uninstall_hook()</code> so uninstall does\nnot need to load the complete plugin while its dependencies may be inactive.</p>\n<p>Read <code>references/uninstall-and-multisite.md</code> before implementing destructive\ncleanup; it contains the isolated-file pattern and failure cases.</p>\n<h2>Critical rules</h2>\n<ul>\n<li><strong>Activation is one-shot setup, NOT runtime configuration.</strong> No <code>add_action</code> here.</li>\n<li><strong>Deactivation is REVERSIBLE.</strong> Clear cron + active-state transients. Nothing destructive.</li>\n<li><strong>Uninstall is DESTRUCTIVE.</strong> Clear everything the plugin owns, in <code>uninstall.php</code>, multisite-aware.</li>\n<li><strong><code>uninstall.php</code> runs without your classes.</strong> Use raw WP functions and inline strings (or manually <code>require</code> a constants file).</li>\n<li><strong><code>wp_unschedule_hook($hook)</code> over <code>wp_clear_scheduled_hook($hook, $args)</code></strong> — args-mismatch means orphaned events. The former clears all events for a hook regardless of args (since WP 4.9, <code>wp-includes/cron.php</code>).</li>\n<li><strong><code>add_option</code> for activation seeding, never <code>update_option</code></strong> — preserves existing user preferences across reactivation.</li>\n<li><strong><code>require_once 'wp-admin/includes/upgrade.php'</code> before any <code>dbDelta()</code> call.</strong></li>\n<li><strong>Multisite cron is per-blog; multisite options are per-site OR network-wide.</strong> Use <code>delete_site_option</code> for network-level data, loop sites for per-site cleanup.</li>\n<li><strong>Offer a <code>preserve_data_on_uninstall</code> toggle.</strong> Some users reinstall; uninstall ≠ \"I want to lose everything\".</li>\n</ul>\n<h2>Common mistakes</h2>\n<p>Use <code>references/uninstall-and-multisite.md</code> to review reactivation overwrites,\ncron-argument mismatches, unavailable plugin classes, non-trivial registered\nuninstall callbacks, and destructive deactivation.</p>\n<h2>Cross-references</h2>\n<ul>\n<li>Run <strong><code>wp-plugin-bootstrap</code></strong> first — it covers the main plugin file (header, constants, autoload, requirements check at activation entry).</li>\n<li>Run <strong><code>wp-security-audit</code></strong> on the activation handler — it's a write endpoint with admin context.</li>\n<li>Run <strong><code>wp-i18n-audit</code></strong> if the lifecycle handlers emit translated strings (admin notices, <code>wp_die</code> messages).</li>\n</ul>\n<h2>What this skill does NOT cover</h2>\n<ul>\n<li>Custom cron interval registration (<code>cron_schedules</code> filter), Action Scheduler integration — adjacent topic, separate skill (<code>wp-plugin-cron</code>, planned).</li>\n<li>Database schema/data migrations beyond the initial <code>dbDelta</code> — versioned update migrations need their own pattern; do not rely only on <code>upgrader_process_complete</code>.</li>\n<li>WP-CLI <code>wp plugin activate</code> / <code>wp plugin deactivate</code> semantics — same hooks fire, but the multisite detection (<code>is_network_admin()</code>) is different.</li>\n<li>Theme uninstall — themes don't have a <code>uninstall.php</code> equivalent; theme cleanup is generally less mechanized.</li>\n</ul>\n<h2>References</h2>\n<ul>\n<li>Isolated uninstall and multisite examples:\n<code>references/uninstall-and-multisite.md</code>.</li>\n<li>Uninstall methods: <a href=\"https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/\">Plugin Handbook</a></li>\n<li><code>register_activation_hook</code> / <code>register_deactivation_hook</code> / <code>register_uninstall_hook</code>: <code>wp-includes/plugin.php</code></li>\n<li><code>uninstall_plugin()</code> (the function that includes <code>uninstall.php</code>): <code>wp-admin/includes/plugin.php:1302-1330</code></li>\n<li><code>wp_unschedule_hook</code> (since 4.9): <code>wp-includes/cron.php</code></li>\n<li><code>dbDelta</code>: <code>wp-admin/includes/upgrade.php</code></li>\n<li>Multisite blog switching: <code>wp-includes/ms-blogs.php</code></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/register_activation_hook/\">https://developer.wordpress.org/reference/functions/register_activation_hook/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/register_deactivation_hook/\">https://developer.wordpress.org/reference/functions/register_deactivation_hook/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/wp_unschedule_hook/\">https://developer.wordpress.org/reference/functions/wp_unschedule_hook/</a></li>\n<li>Official documentation: <a href=\"https://developer.wordpress.org/reference/functions/dbDelta/\">https://developer.wordpress.org/reference/functions/dbDelta/</a></li>\n</ul>\n","files":[{"path":"references/uninstall-and-multisite.md","sizeBytes":3708,"isText":true},{"path":"SKILL.md","sizeBytes":14797,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-16T14:57:03.852486Z","sha256":"E8C404F8877DFD7A14D6A20F654C8EF335D1ABAD3CC962BBA05E8632AEDDBFE8","sizeBytes":7515},"review":null,"source":{"repositoryUrl":"https://github.com/Lonsdale201/wp-agent-skills","path":"plugin-scaffold/wp-plugin-lifecycle","license":"MIT","commit":"8820ff3c301066297e696611e3bc4ebeb47d1851","subtreeSha":"197AAD2E79CC70F32317FD2551D6782FE405664FE1E9AAD4340E3D4DBF2E2D2D","lastSyncedAt":"2026-09-22T13:51:11.366991Z"},"reviewedAt":"2026-09-16T15:14:48.592135Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/Lonsdale201/wp-agent-skills/tree/main/plugin-scaffold/wp-plugin-lifecycle"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install lonsdale201-wp-agent-skills@llmmart"},{"target":"git","command":"git clone https://github.com/Lonsdale201/wp-agent-skills.git"}]}