{"id":20875,"date":"2026-10-12T10:21:00","date_gmt":"2026-10-12T08:21:00","guid":{"rendered":"https:\/\/www.lukaswojcik.com\/blog\/?p=20875"},"modified":"2026-10-09T22:46:42","modified_gmt":"2026-10-09T20:46:42","slug":"plugin-lifecycle-uninstall","status":"publish","type":"post","link":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/","title":{"rendered":"Activation, deactivation, uninstall: the plugin lifecycle"},"content":{"rendered":"<p>Many plugins leave traces long after they have stopped doing useful work: a table nobody reads, cron events with no callback left to handle them, or a capability on the administrator role that no longer controls access to any screen. WordPress offers three separate entry points for activation, deactivation and uninstall, with some behaviour that is easy to overlook. The activation hook does not run on updates, a network activation on multisite fires exactly once instead of once per site, and an uninstall routine registered as a closure ends in a fatal error.<\/p>\n<p>This article follows a small but complete plugin through all three phases. It creates its own table with <code>dbDelta()<\/code>, stores a schema version and migrates on updates, schedules a daily cron event, grants a capability to administrators and removes all of it again through an <code>uninstall.php<\/code> that deletes data only after an explicit opt-in. Every statement about core behaviour is checked against the handbook, the function reference and the source code of WordPress 7.1.2, the current release on 29.09.2026.<\/p>\n<h2>Three phases, three entry points<\/h2>\n<p>The Plugin Handbook separates the phases cleanly: activation is the place for rewrite rules, custom tables and default options, deactivation removes temporary data such as caches, and only uninstall should remove permanent data like options and tables.<\/p>\n<p>Technically, <code>register_activation_hook()<\/code> and <code>register_deactivation_hook()<\/code> are thin wrappers. Both call <code>plugin_basename()<\/code> on the file passed in and attach the callback to a dynamic action, <code>activate_{$plugin}<\/code> or <code>deactivate_{$plugin}<\/code>. The first argument therefore has to be the main plugin file, normally <code>__FILE__<\/code> in that very file. Uninstall works differently: either WordPress includes an <code>uninstall.php<\/code> from the plugin folder, or it calls a callback registered with <code>register_uninstall_hook()<\/code>. Both variants run only when the plugin is deleted, not when it is deactivated.<\/p>\n<table>\n<thead>\n<tr>\n<th>Phase<\/th>\n<th>Entry point<\/th>\n<th>Example plugin: creates or removes<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Activation<\/td>\n<td><code>register_activation_hook()<\/code><\/td>\n<td>creates table, schema version, capability, cron event<\/td>\n<\/tr>\n<tr>\n<td>Update<\/td>\n<td>no hook, version check on <code>plugins_loaded<\/code><\/td>\n<td>same setup routine, plus data migration<\/td>\n<\/tr>\n<tr>\n<td>Deactivation<\/td>\n<td><code>register_deactivation_hook()<\/code><\/td>\n<td>removes cron event and transient, keeps everything else<\/td>\n<\/tr>\n<tr>\n<td>Uninstall<\/td>\n<td><code>uninstall.php<\/code><\/td>\n<td>always removes capability, cron and cache; table, options and user meta only after opt-in<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<figure class=\"lw-diagram\">\n<img loading=\"lazy\" decoding=\"async\" src=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/diagrams\/wp-plugin-lebenszyklus-en.png?v=20260930\" width=\"1120\" height=\"580\" alt=\"Plugin lifecycle of the example LW Search Log: activation creates table, version option, capability and daily cron event; deactivation only removes cron event and transient; uninstall always removes capability and cron, and drops table and user meta only after opt-in; updates rerun the setup\"><figcaption>Example plugin LW Search Log: activation sets up, deactivation stops, uninstall deletes data only after opt-in<\/figcaption><\/figure>\n<h2>Activation is not an update hook<\/h2>\n<p>Since WordPress 3.1 the activation hook no longer fires on updates. Andrew Nacin explained the change on Make WordPress Core in October 2010: before that, the hook ran on single updates but not on bulk updates, and manual updates via FTP or SVN never triggered it. The recommendation from the same post still applies: a version is stored in the database and compared with the version in the code, and the upgrade procedure runs whenever the two differ.<\/p>\n<p>The current source confirms this. Before an update, <code>Plugin_Upgrader::deactivate_plugin_before_upgrade()<\/code> deactivates an active plugin with <code>deactivate_plugins( $plugin, true )<\/code>, and the second argument means silent, so no deactivation hook runs. During background updates via cron the plugin is not deactivated at all. Afterwards, <code>wp-admin\/update.php<\/code> reactivates the plugin with <code>activate_plugin( $plugin, '', ! empty( $_GET['networkwide'] ), true )<\/code>, again silently. The docblock of <code>activate_plugin()<\/code> confirms that silent activation skips the activation hooks.<\/p>\n<p>The hook <code>upgrader_process_complete<\/code> is a poor fit for migrations. It fires in the request that performed the update, and a note in the hook reference points out that the code running at that moment is still the old version of the plugin. A migration written for version 2 would therefore only run one update later. The version check on <code>plugins_loaded<\/code>, as recommended by the handbook chapter on custom tables, runs on every request with the new code, no matter how the update arrived.<\/p>\n<h2>Custom table with dbDelta and a versioned schema<\/h2>\n<p>Whether a separate table is worth it is covered in <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/web-development\/wordpress-database-performance-limits-of-wp_postmeta-and-how-to-build\/\">the article on the performance limits of wp_postmeta<\/a>; here it is a given. <code>dbDelta()<\/code> lives in <code>wp-admin\/includes\/upgrade.php<\/code>, which has to be included explicitly. The function compares a <code>CREATE TABLE<\/code> statement with the existing table and adds what is missing. It parses the SQL as text and is strict about formatting. According to the handbook:<\/p>\n<ul>\n<li>each field on its own line;<\/li>\n<li>two spaces between <code>PRIMARY KEY<\/code> and the definition of the key;<\/li>\n<li><code>KEY<\/code> instead of its synonym <code>INDEX<\/code>, and at least one <code>KEY<\/code>;<\/li>\n<li>no apostrophes or backticks around field names;<\/li>\n<li>field types in lower case, SQL keywords such as <code>CREATE TABLE<\/code> in upper case;<\/li>\n<li>a length for every field type that accepts one, for example <code>int(11)<\/code>.<\/li>\n<\/ul>\n<p>The function reference adds a few limits: <code>dbDelta()<\/code> does not drop columns, a renamed field ends up as an additional column, and the returned messages are not proof that a change was actually executed. Renaming or removing columns and converting data therefore belong in explicit migration steps. The character set comes from <code>$wpdb-&gt;get_charset_collate()<\/code>.<\/p>\n<p>The complete main file of the example plugin follows. It logs front-end searches into a table, prunes rows older than 90 days once a day and shows the most frequent terms under Tools to everyone with the capability <code>lwsl_view_log<\/code>.<\/p>\n<pre><code>&lt;?php\n\/**\n * Plugin Name:       LW Search Log\n * Description:       Stores front-end search terms in a custom table and prunes old rows once a day.\n * Version:           1.1.0\n * Requires at least: 6.5\n * Requires PHP:      7.4\n * Author:            Lukas Wojcik\n * License:           GPL-2.0-or-later\n * License URI:       https:\/\/www.gnu.org\/licenses\/gpl-2.0.html\n * Text Domain:       lw-search-log\n *\n * @package LW_Search_Log\n *\/\n\ndefined( 'ABSPATH' ) || exit;\n\nconst LWSL_DB_VERSION = 2;\nconst LWSL_CRON_HOOK  = 'lwsl_prune_log';\nconst LWSL_CAP        = 'lwsl_view_log';\n\n\/**\n * Returns the log table name of the current site.\n *\n * @return string\n *\/\nfunction lwsl_table() {\n\tglobal $wpdb;\n\treturn $wpdb-&gt;prefix . 'lwsl_searches';\n}\n\n\/**\n * Creates the log table or brings it to the current structure.\n *\/\nfunction lwsl_create_table() {\n\tglobal $wpdb;\n\n\trequire_once ABSPATH . 'wp-admin\/includes\/upgrade.php';\n\n\t$table   = lwsl_table();\n\t$collate = $wpdb-&gt;get_charset_collate();\n\n\t\/\/ dbDelta() parses this text: one column per line, two spaces after\n\t\/\/ PRIMARY KEY, KEY instead of INDEX, no backticks around names.\n\t$sql = \"CREATE TABLE {$table} (\n\tid bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n\tterm varchar(190) NOT NULL DEFAULT '',\n\tresult_count int(11) unsigned NOT NULL DEFAULT 0,\n\tsearched_at datetime NOT NULL,\n\tPRIMARY KEY  (id),\n\tKEY searched_at (searched_at)\n) {$collate};\";\n\n\tdbDelta( $sql );\n}\n\n\/**\n * Data migration for schema version 2: terms are stored in lower case.\n * The new column result_count is added by dbDelta() itself.\n *\/\nfunction lwsl_migrate_to_2() {\n\tglobal $wpdb;\n\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching\n\t$wpdb-&gt;query( $wpdb-&gt;prepare( 'UPDATE %i SET term = LOWER(term)', lwsl_table() ) );\n}\n\n\/**\n * Brings the current site to the state this code expects.\n * Idempotent: used on activation, after updates and for new sites.\n *\/\nfunction lwsl_setup_site() {\n\t$installed = (int) get_option( 'lwsl_db_version', 0 );\n\n\tlwsl_create_table();\n\n\tif ( $installed &gt; 0 &amp;&amp; $installed &lt; 2 ) {\n\t\tlwsl_migrate_to_2();\n\t}\n\n\t$role = get_role( 'administrator' );\n\tif ( $role &amp;&amp; ! $role-&gt;has_cap( LWSL_CAP ) ) {\n\t\t$role-&gt;add_cap( LWSL_CAP );\n\t}\n\n\tif ( ! wp_next_scheduled( LWSL_CRON_HOOK ) ) {\n\t\twp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', LWSL_CRON_HOOK );\n\t}\n\n\t\/\/ Read on every request by lwsl_maybe_upgrade(), so it stays autoloaded.\n\tupdate_option( 'lwsl_db_version', LWSL_DB_VERSION, true );\n}\n\n\/**\n * Runs a callback once for every site of the network, 100 sites per query.\n *\n * @param callable $callback Function without parameters.\n *\/\nfunction lwsl_for_each_site( $callback ) {\n\t$offset = 0;\n\tdo {\n\t\t$site_ids = get_sites(\n\t\t\tarray(\n\t\t\t\t'fields' =&gt; 'ids',\n\t\t\t\t'number' =&gt; 100,\n\t\t\t\t'offset' =&gt; $offset,\n\t\t\t)\n\t\t);\n\t\tforeach ( $site_ids as $site_id ) {\n\t\t\tswitch_to_blog( $site_id );\n\t\t\tcall_user_func( $callback );\n\t\t\trestore_current_blog();\n\t\t}\n\t\t$offset += 100;\n\t} while ( 100 === count( $site_ids ) );\n}\n\n\/**\n * Activation: set up the current site, or every site on network activation.\n *\n * @param bool $network_wide True when the plugin is network-activated.\n *\/\nfunction lwsl_activate( $network_wide = false ) {\n\tif ( is_multisite() &amp;&amp; $network_wide ) {\n\t\t\/\/ On large networks the sites are set up lazily by lwsl_maybe_upgrade().\n\t\tif ( ! wp_is_large_network() ) {\n\t\t\tlwsl_for_each_site( 'lwsl_setup_site' );\n\t\t}\n\t\treturn;\n\t}\n\tlwsl_setup_site();\n}\nregister_activation_hook( __FILE__, 'lwsl_activate' );\n\n\/**\n * Update path: activation hooks do not run on updates, so the stored\n * schema version is compared with the code on every request.\n *\/\nfunction lwsl_maybe_upgrade() {\n\tif ( (int) get_option( 'lwsl_db_version', 0 ) &lt; LWSL_DB_VERSION ) {\n\t\tlwsl_setup_site();\n\t}\n}\nadd_action( 'plugins_loaded', 'lwsl_maybe_upgrade' );\n\n\/**\n * Stops scheduled work and drops caches on the current site. Keeps all data.\n *\/\nfunction lwsl_teardown_site() {\n\twp_clear_scheduled_hook( LWSL_CRON_HOOK );\n\tdelete_transient( 'lwsl_top_terms' );\n}\n\n\/**\n * Deactivation: current site, or every site on network deactivation.\n *\n * @param bool $network_deactivating True when the plugin is network-deactivated.\n *\/\nfunction lwsl_deactivate( $network_deactivating = false ) {\n\tif ( is_multisite() &amp;&amp; $network_deactivating ) {\n\t\tlwsl_for_each_site( 'lwsl_teardown_site' );\n\t\treturn;\n\t}\n\tlwsl_teardown_site();\n}\nregister_deactivation_hook( __FILE__, 'lwsl_deactivate' );\n\n\/**\n * Cron callback: deletes log rows older than 90 days.\n *\/\nfunction lwsl_prune_log() {\n\tglobal $wpdb;\n\t$cutoff = gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS );\n\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching\n\t$wpdb-&gt;query( $wpdb-&gt;prepare( 'DELETE FROM %i WHERE searched_at &lt; %s', lwsl_table(), $cutoff ) );\n\tdelete_transient( 'lwsl_top_terms' );\n}\nadd_action( LWSL_CRON_HOOK, 'lwsl_prune_log' );\n\n\/**\n * Logs the search term of the main front-end query.\n *\/\nfunction lwsl_log_search() {\n\tglobal $wpdb, $wp_query;\n\n\tif ( ! is_search() || is_paged() ) {\n\t\treturn;\n\t}\n\n\t$term = mb_substr( sanitize_text_field( get_search_query( false ) ), 0, 190 );\n\tif ( '' === $term ) {\n\t\treturn;\n\t}\n\n\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching\n\t$wpdb-&gt;query(\n\t\t$wpdb-&gt;prepare(\n\t\t\t'INSERT INTO %i (term, result_count, searched_at) VALUES (LOWER(%s), %d, %s)',\n\t\t\tlwsl_table(),\n\t\t\t$term,\n\t\t\t(int) $wp_query-&gt;found_posts,\n\t\t\tcurrent_time( 'mysql', true )\n\t\t)\n\t);\n}\nadd_action( 'wp', 'lwsl_log_search' );\n\n\/**\n * Adds the log page under Tools, visible with the plugin capability only.\n *\/\nfunction lwsl_admin_menu() {\n\tadd_management_page(\n\t\t__( 'Search log', 'lw-search-log' ),\n\t\t__( 'Search log', 'lw-search-log' ),\n\t\tLWSL_CAP,\n\t\t'lwsl-search-log',\n\t\t'lwsl_render_page'\n\t);\n}\nadd_action( 'admin_menu', 'lwsl_admin_menu' );\n\n\/**\n * Renders the top terms (cached in a transient) and the searches since the\n * current user's last visit (stored in user meta).\n *\/\nfunction lwsl_render_page() {\n\tglobal $wpdb;\n\n\t$user_id   = get_current_user_id();\n\t$last_seen = (int) get_user_meta( $user_id, 'lwsl_last_viewed', true );\n\tupdate_user_meta( $user_id, 'lwsl_last_viewed', time() );\n\n\t$top = get_transient( 'lwsl_top_terms' );\n\tif ( false === $top ) {\n\t\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cached in a transient.\n\t\t$top = $wpdb-&gt;get_results(\n\t\t\t$wpdb-&gt;prepare( 'SELECT term, COUNT(*) AS hits FROM %i GROUP BY term ORDER BY hits DESC LIMIT 20', lwsl_table() )\n\t\t);\n\t\tset_transient( 'lwsl_top_terms', $top, HOUR_IN_SECONDS );\n\t}\n\n\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching\n\t$new = (int) $wpdb-&gt;get_var(\n\t\t$wpdb-&gt;prepare( 'SELECT COUNT(*) FROM %i WHERE searched_at &gt; %s', lwsl_table(), gmdate( 'Y-m-d H:i:s', $last_seen ) )\n\t);\n\n\techo '&lt;div class=\"wrap\"&gt;&lt;h1&gt;' . esc_html__( 'Search log', 'lw-search-log' ) . '&lt;\/h1&gt;&lt;p&gt;';\n\techo esc_html(\n\t\tsprintf(\n\t\t\t\/* translators: %d: number of searches since the last visit *\/\n\t\t\t__( 'New searches since the last visit: %d', 'lw-search-log' ),\n\t\t\t$new\n\t\t)\n\t);\n\techo '&lt;\/p&gt;&lt;ol&gt;';\n\tforeach ( $top as $row ) {\n\t\tprintf( '&lt;li&gt;%s (%d)&lt;\/li&gt;', esc_html( $row-&gt;term ), (int) $row-&gt;hits );\n\t}\n\techo '&lt;\/ol&gt;&lt;\/div&gt;';\n}\n\n\/**\n * Multisite: drop the log table together with a deleted site.\n *\n * @param string[] $tables  Tables WordPress is about to drop.\n * @param int      $site_id ID of the deleted site.\n * @return string[]\n *\/\nfunction lwsl_drop_site_table( $tables, $site_id ) {\n\tglobal $wpdb;\n\t$tables[] = $wpdb-&gt;get_blog_prefix( $site_id ) . 'lwsl_searches';\n\treturn $tables;\n}\nadd_filter( 'wpmu_drop_tables', 'lwsl_drop_site_table', 10, 2 );<\/code><\/pre>\n<p>The central function is <code>lwsl_setup_site()<\/code>. It is idempotent: <code>dbDelta()<\/code> changes nothing when the table already matches, the capability is only written when it is missing, and the cron event is only scheduled when <code>wp_next_scheduled()<\/code> finds none. That makes it safe to call from the activation hook, from the version check and for sites not yet set up on multisite. Schema version 2 shows both kinds of migration. The new column <code>result_count<\/code> is added by <code>dbDelta()<\/code> alone, whereas converting the existing terms to lower case is a data migration that needs its own step and only runs when the stored version is 1.<\/p>\n<p>Two details are easy to miss. The option <code>lwsl_db_version<\/code> is read on every request, so it is saved with autoload enabled; otherwise every page view would cost an additional query. On every call, <code>add_cap()<\/code> passes the complete role option to <code>update_option()<\/code>. That function writes to the database only when the capability is new. Even so, the example adds the capability only during setup, never on <code>init<\/code>. Table names use the placeholder <code>%i<\/code>, which <code>$wpdb-&gt;prepare()<\/code> has supported for identifiers since WordPress 6.2.<\/p>\n<h2>Deactivation: stop, but do not delete<\/h2>\n<p>Deactivation is often temporary, for example while troubleshooting. The example therefore removes only what would keep running without the plugin or would be stale on reactivation: the cron event and the cached top list in the transient. The table, the schema version, the capability and the user meta stay.<\/p>\n<p>For cron, two functions are available. <code>wp_clear_scheduled_hook()<\/code> removes all events of a hook with the specified arguments, and the reference stresses that the arguments must match exactly. If a plugin schedules the same hook with different arguments, only <code>wp_unschedule_hook()<\/code> helps; available since 4.9, it removes all events of a hook regardless of their arguments, and its docblock names cleaning up the cron queue on deactivation as a use case. The example schedules without arguments; the uninstall routine still uses the second function to be safe.<\/p>\n<p>The deactivation callback receives a boolean that is true when the plugin is network-deactivated on multisite. <code>lwsl_deactivate()<\/code> uses it to clean up all sites. The capability deliberately stays in place during deactivation. Without the plugin no code checks it, and removing and re-adding it would mean two needless writes to the role option per site.<\/p>\n<h2>Uninstall: uninstall.php, register_uninstall_hook and the opt-in<\/h2>\n<p>When a plugin is deleted, <code>delete_plugins()<\/code> calls <code>uninstall_plugin()<\/code> for each plugin before the files are removed. The source code settles the priority between the two variants: if an <code>uninstall.php<\/code> exists, WordPress removes any registered callback from the option <code>uninstall_plugins<\/code>, defines the constant <code>WP_UNINSTALL_PLUGIN<\/code> with the plugin basename and includes only this file. The main plugin file is not loaded in this case. That is why the file below repeats every name instead of using constants such as <code>LWSL_CAP<\/code>. Only when no <code>uninstall.php<\/code> exists is the main file included and the stored callback executed on <code>uninstall_{$file}<\/code>; in that case every line of code at file level runs as well.<\/p>\n<p>The check for <code>WP_UNINSTALL_PLUGIN<\/code> at the top of the file protects against direct calls, as the handbook requires. <code>register_uninstall_hook()<\/code> has stricter rules than the other two registration functions because it stores the callback in an option. A closure cannot be serialized and ends in a fatal error; an object method is rejected with a <code>_doing_it_wrong()<\/code> notice. Only function names and static methods are allowed. Every call reads the option and writes it whenever the entry is missing or differs, so the registration belongs in the activation routine, not on every request.<\/p>\n<pre><code>\/\/ Counter-example: what register_uninstall_hook() accepts and what it rejects.\n\n\/\/ Accepted: a function name or a static method. The callback is stored\n\/\/ in the option uninstall_plugins, so register it once, on activation.\nregister_uninstall_hook( __FILE__, 'lwsl_uninstall' );\nregister_uninstall_hook( __FILE__, array( 'LWSL_Plugin', 'uninstall' ) );\n\n\/\/ Rejected: a closure cannot be serialized into that option (fatal error),\n\/\/ an object method triggers _doing_it_wrong() and is not registered.\nregister_uninstall_hook( __FILE__, function () {} );\nregister_uninstall_hook( __FILE__, array( new LWSL_Plugin(), 'uninstall' ) );<\/code><\/pre>\n<p>What does \u201cdelete data on uninstall\u201d mean as an opt-in? A site owner who deletes a plugin is not necessarily saying goodbye to its data; reinstalling to rule out a damaged set of files involves a deletion as well. The example therefore splits the cleanup into two groups. Technical leftovers always go: cron events, transients and the capability are useless without the plugin, and activation recreates them anyway. Content and configuration only go after an explicit yes: the log table, the schema version, the user meta and the switch itself. Without the opt-in, a reinstall finds the old log including its schema version.<\/p>\n<pre><code>&lt;?php\n\/**\n * Uninstall routine of LW Search Log.\n *\n * WordPress includes this file when the plugin is deleted. The main plugin\n * file is not loaded, so every name is spelled out here.\n *\n * @package LW_Search_Log\n *\/\n\n\/\/ Only run when WordPress itself starts the uninstall.\nif ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {\n\texit;\n}\n\n\/**\n * Cleans one site: technical leftovers always, data only after opt-in.\n *\n * @param bool $delete_data Whether the site owner opted in to data removal.\n *\/\nfunction lwsl_uninstall_site( $delete_data ) {\n\tglobal $wpdb;\n\n\t\/\/ Always: scheduled events, caches and the capability.\n\twp_unschedule_hook( 'lwsl_prune_log' );\n\tdelete_transient( 'lwsl_top_terms' );\n\n\tforeach ( wp_roles()-&gt;role_objects as $role ) {\n\t\tif ( $role-&gt;has_cap( 'lwsl_view_log' ) ) {\n\t\t\t$role-&gt;remove_cap( 'lwsl_view_log' );\n\t\t}\n\t}\n\n\tif ( ! $delete_data ) {\n\t\treturn;\n\t}\n\n\t\/\/ Opt-in only: the log table and the schema version.\n\t\/\/ phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange\n\t$wpdb-&gt;query( $wpdb-&gt;prepare( 'DROP TABLE IF EXISTS %i', $wpdb-&gt;prefix . 'lwsl_searches' ) );\n\tdelete_option( 'lwsl_db_version' );\n}\n\n\/\/ The opt-in switch: a network option on multisite, a normal option otherwise.\n$lwsl_delete_data = (bool) get_site_option( 'lwsl_delete_data_on_uninstall', false );\n\nif ( is_multisite() ) {\n\t$lwsl_offset = 0;\n\tdo {\n\t\t$lwsl_site_ids = get_sites(\n\t\t\tarray(\n\t\t\t\t'fields' =&gt; 'ids',\n\t\t\t\t'number' =&gt; 100,\n\t\t\t\t'offset' =&gt; $lwsl_offset,\n\t\t\t)\n\t\t);\n\t\tforeach ( $lwsl_site_ids as $lwsl_site_id ) {\n\t\t\tswitch_to_blog( $lwsl_site_id );\n\t\t\tlwsl_uninstall_site( $lwsl_delete_data );\n\t\t\trestore_current_blog();\n\t\t}\n\t\t$lwsl_offset += 100;\n\t} while ( 100 === count( $lwsl_site_ids ) );\n} else {\n\tlwsl_uninstall_site( $lwsl_delete_data );\n}\n\nif ( $lwsl_delete_data ) {\n\t\/\/ wp_usermeta is shared by all sites: one call removes the key for every user.\n\tdelete_metadata( 'user', 0, 'lwsl_last_viewed', '', true );\n\tdelete_site_option( 'lwsl_delete_data_on_uninstall' );\n}<\/code><\/pre>\n<p>User meta is removed with <code>delete_metadata()<\/code> and the fifth argument <code>$delete_all<\/code> set to true, which deletes the key for all users at once. <code>delete_user_meta()<\/code> would only work for one user ID at a time. <code>delete_transient()<\/code> also takes care of an external object cache: in that case it deletes the entry from the cache instead of the options table. A <code>LIKE<\/code> query on <code>_transient_%<\/code> in <code>wp_options<\/code> does not reach such a cache.<\/p>\n<p>How the switch is set is a matter for the settings page; <span class=\"lw-artikel-geplant\">a separate article in this series<\/span> covers the Settings API. For a test, WP-CLI is enough. <code>wp plugin uninstall<\/code> with <code>--skip-delete<\/code> runs only the uninstall procedure and keeps the files, while <code>wp plugin delete<\/code> removes the files without deactivating or uninstalling. <a href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/wp-cli-delete-commands-which-tables-each-one-touches-and-what-it-leaves\/\">The article on WP-CLI delete commands and the tables they touch<\/a> looks at that difference in more detail.<\/p>\n<pre><code># Single site: opt in, then run only the uninstall routine (files stay).\nwp option update lwsl_delete_data_on_uninstall 1\nwp plugin deactivate lw-search-log\nwp plugin uninstall lw-search-log --skip-delete\n\n# Multisite: the switch is a network option.\nwp site option update lwsl_delete_data_on_uninstall 1\nwp plugin deactivate lw-search-log --network\nwp plugin uninstall lw-search-log --skip-delete\n\n# Deletes the files only: neither deactivation nor uninstall code runs.\nwp plugin delete lw-search-log<\/code><\/pre>\n<h2>Multisite: once per network, but data per site<\/h2>\n<p>On multisite, most data of the example exists per site: the table carries the site prefix, options and transients are per site, cron events live in each site\u2019s <code>cron<\/code> option, and roles in an option made of the site prefix plus <code>user_roles<\/code>. User meta, by contrast, sits in one table for the whole network, and the opt-in switch is stored as a network option via <code>get_site_option()<\/code>, which falls back to a normal option on single sites.<\/p>\n<p>A network activation fires the activation hook exactly once, in the context of the current site, with <code>$network_wide<\/code> set to true. <code>activate_plugin()<\/code> also sets this flag when the plugin header contains <code>Network: true<\/code>. The plugin itself has to take care of all other sites. <code>lwsl_for_each_site()<\/code> does that with <code>get_sites()<\/code> and <code>switch_to_blog()<\/code>. Two pitfalls: <code>get_sites()<\/code> returns at most 100 sites by default, which is why the loop pages explicitly; and <code>switch_to_blog()<\/code> only switches the database context, not the loaded code. Here that does not matter, because the loop only calls functions of this plugin.<\/p>\n<p>On large networks, a loop over all sites during activation can hit time limits. WordPress considers a network large at more than 10,000 sites or users, and <code>wp_is_large_network()<\/code> can be filtered. In that case the example skips the loop and relies on the version check: <code>lwsl_maybe_upgrade()<\/code> runs on every request of every site where the plugin is active, and sets up a site on its first request. This also covers sites created after activation. Anyone who wants to set up new sites immediately can hook into <code>wp_initialize_site<\/code>; core runs its own site initialization there at priority 10, so a plugin callback belongs at a later priority.<\/p>\n<p>Deleting a single site is handled by the filter <code>wpmu_drop_tables<\/code>, which lists the tables WordPress drops together with the site. The example adds its table there regardless of the opt-in, since the whole site disappears anyway. Deleting the plugin itself is reserved for super admins on multisite, as the capability mapping of <code>delete_plugins<\/code> shows, and <code>uninstall.php<\/code> then runs once in the network admin. The loop over all sites in the uninstall routine is therefore not optional; the handbook explicitly warns that this can be resource-intensive on large networks.<\/p>\n<h2>Limits and open points<\/h2>\n<p>The example code was tested on WordPress 7.1.2 (PHP 8.4, MariaDB 11.8) on a single site and on a subdirectory network: activation, network activation, a site created later, deleting a site, an update from schema version 1 to 2, deactivation and uninstall with and without the opt-in. Plugin Check 2.1.0 reports no issue in the two PHP files.<\/p>\n<ul>\n<li>Parallel requests right after an update can run <code>lwsl_setup_site()<\/code> twice. Here that is harmless because every step is idempotent; other migrations need a lock.<\/li>\n<li>The handbook requires a length for integer types such as <code>int(11)<\/code>. On MariaDB 11.8, repeated <code>dbDelta()<\/code> calls with <code>int(11)<\/code> reported no change; without a length they reported \u201cChanged type\u201d on every call, because MariaDB shows <code>int(10)<\/code>. For MySQL 8.0.17 and later, core has ignored pure width differences since WordPress 6.1 (according to the source, not measured).<\/li>\n<li>Capabilities granted directly to individual users are stored in user meta and are not removed by the loop over roles.<\/li>\n<li>In installations with several networks, <code>get_sites()<\/code> without <code>network_id<\/code> returns the sites of all networks, while the opt-in switch is stored per network. The right behaviour for that case remains open.<\/li>\n<li>Deletions via FTP, Git or deployment scripts bypass <code>uninstall.php<\/code> completely. No plugin can clean up in that case.<\/li>\n<\/ul>\n<div class=\"lw-faq\">\n<h2>Questions and answers<\/h2>\n<h3>Does uninstall.php run when a plugin is removed via FTP or with wp plugin delete?<\/h3>\n<p>No. WordPress calls the uninstall routine only from <code>delete_plugins()<\/code>, that is, when the plugin is deleted in the dashboard. WP-CLI offers <code>wp plugin uninstall<\/code> for this; according to its documentation, <code>wp plugin delete<\/code> removes the files without deactivating or uninstalling. With FTP, Git or deployment scripts, everything stays in the database.<\/p>\n<h3>What happens if a plugin uses both uninstall.php and register_uninstall_hook()?<\/h3>\n<p>The file wins. <code>uninstall_plugin()<\/code> first checks whether <code>uninstall.php<\/code> exists, then removes the registered callback from the option <code>uninstall_plugins<\/code> and includes only the file. The callback is never called in that case.<\/p>\n<h3>Why does the version check run on plugins_loaded and not on admin_init?<\/h3>\n<p>Updates do not only arrive through the dashboard but also via WP-CLI, automatic background updates or uploaded files. On <code>admin_init<\/code>, the migration would only run once somebody opens the dashboard; until then, the new code runs on the front end against the old schema. <code>plugins_loaded<\/code> runs on every request, and the handbook places the check exactly there. The schema version option should therefore be autoloaded.<\/p>\n<\/div>\n<div class=\"lw-quellen\">\n<h2>Sources<\/h2>\n<p>All sources retrieved on 29.09.2026.<\/p>\n<ul>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/plugin-basics\/activation-deactivation-hooks\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Activation \/ Deactivation Hooks<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/plugin-basics\/uninstall-methods\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Uninstall Methods<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/plugins\/creating-tables-with-plugins\/\" target=\"_blank\" rel=\"noopener noreferrer\">Plugin Handbook: Creating Tables with Plugins<\/a><\/li>\n<li><a href=\"https:\/\/make.wordpress.org\/core\/2010\/10\/27\/plugin-activation-hooks-no-longer-fire-for-updates\/\" target=\"_blank\" rel=\"noopener noreferrer\">Make WordPress Core: Plugin activation hooks no longer fire for updates (27.10.2010)<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/register_deactivation_hook\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: register_deactivation_hook()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/register_uninstall_hook\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: register_uninstall_hook()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/dbdelta\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: dbDelta()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/hooks\/upgrader_process_complete\/\" target=\"_blank\" rel=\"noopener noreferrer\">Hook reference: upgrader_process_complete<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/wp_clear_scheduled_hook\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: wp_clear_scheduled_hook()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/wp_unschedule_hook\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: wp_unschedule_hook()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/get_sites\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: get_sites()<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/reference\/functions\/switch_to_blog\/\" target=\"_blank\" rel=\"noopener noreferrer\">Function reference: switch_to_blog()<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-admin\/includes\/plugin.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-admin\/includes\/plugin.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-admin\/update.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-admin\/update.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-admin\/includes\/class-plugin-upgrader.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: class-plugin-upgrader.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/plugin.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-includes\/plugin.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/option.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-includes\/option.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/meta.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-includes\/meta.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/class-wp-roles.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: class-wp-roles.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/cron.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-includes\/cron.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/ms-site.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: wp-includes\/ms-site.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/ms-default-filters.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: ms-default-filters.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/ms-functions.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: ms-functions.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/capabilities.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: capabilities.php<\/a><\/li>\n<li><a href=\"https:\/\/github.com\/WordPress\/wordpress-develop\/blob\/7.1.2\/src\/wp-includes\/class-wpdb.php\" target=\"_blank\" rel=\"noopener noreferrer\">WordPress 7.1.2 source: class-wpdb.php<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/plugin\/uninstall\/\" target=\"_blank\" rel=\"noopener noreferrer\">WP-CLI: wp plugin uninstall<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/plugin\/delete\/\" target=\"_blank\" rel=\"noopener noreferrer\">WP-CLI: wp plugin delete<\/a><\/li>\n<li><a href=\"https:\/\/developer.wordpress.org\/cli\/commands\/site\/option\/update\/\" target=\"_blank\" rel=\"noopener noreferrer\">WP-CLI: wp site option update<\/a><\/li>\n<\/ul>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Activation hooks skip updates, network activation fires once, and uninstall.php runs without the main plugin file. A complete example plugin walks through all three phases.<\/p>\n","protected":false},"author":1,"featured_media":20929,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[9],"tags":[91104,91093,91096],"class_list":["post-20875","post","type-post","status-publish","format-standard","hentry","category-wordpress-plugins-tricks","tag-mysql","tag-php","tag-wordpress"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v28.1 - https:\/\/yoast.com\/product\/yoast-seo-wordpress\/ -->\n<title>Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik<\/title>\n<meta name=\"description\" content=\"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.\" \/>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik\" \/>\n<meta property=\"og:description\" content=\"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/\" \/>\n<meta property=\"og:site_name\" content=\"Lukas Wojcik - Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-10-12T08:21:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png\" \/>\n\t<meta property=\"og:image:width\" content=\"1200\" \/>\n\t<meta property=\"og:image:height\" content=\"630\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/png\" \/>\n<meta name=\"author\" content=\"Lukas Wojcik\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Lukas Wojcik\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"21 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\\\/\\\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#article\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/\"},\"author\":{\"name\":\"Lukas Wojcik\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"headline\":\"Activation, deactivation, uninstall: the plugin lifecycle\",\"datePublished\":\"2026-10-12T08:21:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/\"},\"wordCount\":2434,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20875-plugin-lifecycle-uninstall.png\",\"keywords\":[\"MySQL\",\"PHP\",\"WordPress\"],\"articleSection\":[\"WordPress Plugins &amp; Tricks\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/\",\"name\":\"Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik\",\"isPartOf\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#primaryimage\"},\"image\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#primaryimage\"},\"thumbnailUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20875-plugin-lifecycle-uninstall.png\",\"datePublished\":\"2026-10-12T08:21:00+00:00\",\"description\":\"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.\",\"breadcrumb\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#primaryimage\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20875-plugin-lifecycle-uninstall.png\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/09\\\/fi-20875-plugin-lifecycle-uninstall.png\",\"width\":1200,\"height\":630,\"caption\":\"Activation, deactivation, uninstall: the plugin lifecycle\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/en\\\/wordpress-plugins-tricks\\\/plugin-lifecycle-uninstall\\\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"Activation, deactivation, uninstall: the plugin lifecycle\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#website\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/\",\"name\":\"Lukas Wojcik - Blog\",\"description\":\"\",\"publisher\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\"},\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":[\"Person\",\"Organization\"],\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/#\\\/schema\\\/person\\\/895f7604f9b6b71aad9bba33af28d0f9\",\"name\":\"Lukas Wojcik\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"url\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"contentUrl\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\",\"width\":424,\"height\":636,\"caption\":\"Lukas Wojcik\"},\"logo\":{\"@id\":\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\\\/wp-content\\\/uploads\\\/2026\\\/07\\\/lw-x2.jpg\"},\"sameAs\":[\"https:\\\/\\\/www.lukaswojcik.com\\\/blog\"]}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik","description":"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/","og_locale":"en_US","og_type":"article","og_title":"Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik","og_description":"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.","og_url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/","og_site_name":"Lukas Wojcik - Blog","article_published_time":"2026-10-12T08:21:00+00:00","og_image":[{"width":1200,"height":630,"url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png","type":"image\/png"}],"author":"Lukas Wojcik","twitter_card":"summary_large_image","twitter_misc":{"Written by":"Lukas Wojcik","Est. reading time":"21 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#article","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/"},"author":{"name":"Lukas Wojcik","@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"headline":"Activation, deactivation, uninstall: the plugin lifecycle","datePublished":"2026-10-12T08:21:00+00:00","mainEntityOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/"},"wordCount":2434,"commentCount":0,"publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png","keywords":["MySQL","PHP","WordPress"],"articleSection":["WordPress Plugins &amp; Tricks"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/","url":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/","name":"Activation, deactivation, uninstall: the plugin lifecycle | Lukas Wojcik","isPartOf":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#primaryimage"},"image":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#primaryimage"},"thumbnailUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png","datePublished":"2026-10-12T08:21:00+00:00","description":"WordPress plugin lifecycle: dbDelta table with versioned migration, cron cleanup, capabilities and a multisite-aware uninstall.php with opt-in.","breadcrumb":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#primaryimage","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/09\/fi-20875-plugin-lifecycle-uninstall.png","width":1200,"height":630,"caption":"Activation, deactivation, uninstall: the plugin lifecycle"},{"@type":"BreadcrumbList","@id":"https:\/\/www.lukaswojcik.com\/blog\/en\/wordpress-plugins-tricks\/plugin-lifecycle-uninstall\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/www.lukaswojcik.com\/blog\/"},{"@type":"ListItem","position":2,"name":"Activation, deactivation, uninstall: the plugin lifecycle"}]},{"@type":"WebSite","@id":"https:\/\/www.lukaswojcik.com\/blog\/#website","url":"https:\/\/www.lukaswojcik.com\/blog\/","name":"Lukas Wojcik - Blog","description":"","publisher":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9"},"potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/www.lukaswojcik.com\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":["Person","Organization"],"@id":"https:\/\/www.lukaswojcik.com\/blog\/#\/schema\/person\/895f7604f9b6b71aad9bba33af28d0f9","name":"Lukas Wojcik","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","url":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","contentUrl":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg","width":424,"height":636,"caption":"Lukas Wojcik"},"logo":{"@id":"https:\/\/www.lukaswojcik.com\/blog\/wp-content\/uploads\/2026\/07\/lw-x2.jpg"},"sameAs":["https:\/\/www.lukaswojcik.com\/blog"]}]}},"_links":{"self":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20875","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/comments?post=20875"}],"version-history":[{"count":2,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20875\/revisions"}],"predecessor-version":[{"id":21841,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/posts\/20875\/revisions\/21841"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media\/20929"}],"wp:attachment":[{"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/media?parent=20875"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/categories?post=20875"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.lukaswojcik.com\/blog\/wp-json\/wp\/v2\/tags?post=20875"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}