LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog Overview /WordPress Plugins & Tricks / Activation, deactivation, uninstall: the plugin lifecycle
Read this article in other languages:

Activation, deactivation, uninstall: the plugin lifecycle

Activation, deactivation, uninstall: the plugin lifecycle

Management Summary

A WordPress plugin needs clear rules for starting, pausing and removal. Activation prepares operation, temporary deactivation should preserve useful data, and uninstall offers final cleanup. This distinction matters during troubleshooting and software changes: stopping a feature should not unexpectedly erase its history. The example separates these stages and makes permanent data deletion an explicit choice. Updates need their own preparation, because activation does not run again automatically.

Contents
  1. Three phases, three entry points
  2. Activation is not an update hook
  3. Custom table with dbDelta and a versioned schema
  4. Deactivation: stop, but do not delete
  5. Uninstall: uninstall.php, register_uninstall_hook and the opt-in
  6. Multisite: once per network, but data per site
  7. Limits and open points
  8. Questions and answers
  9. Sources

A disabled plugin can leave behind tables, scheduled tasks and access permissions. WordPress separates three steps: activation prepares the plugin, deactivation stops normal operation, and uninstall removes leftovers. A hook is a point where WordPress calls a registered function. Updates need separate handling: the activation hook does not run again during an update.

LW Search Log stores search terms in a custom database table. A version number describes the table structure. A migration changes stored data to match a newer code version. A scheduled task removes old entries, and a custom permission controls access to the report. The uninstall.php file deletes log data only after explicit consent. The explanations refer to WordPress 7.1.2; the limits of the revised example are listed at the end.

Three phases, three entry points

Activation prepares items such as tables and default options. Deactivation removes temporary data, including caches, and stops scheduled tasks. This example keeps permanent content. Uninstall offers the final cleanup. This separation is a recommended plugin design, not a WordPress mechanism that prevents earlier deletion.

register_activation_hook() and register_deactivation_hook() register functions for these steps. The first argument identifies the main file containing the plugin header; __FILE__ fits when called there. For uninstall, WordPress loads uninstall.php or calls the function registered with register_uninstall_hook(). Normal deletion in the admin area starts this process. WP-CLI can also uninstall while keeping the files. Deactivation alone does not run uninstall.

PhaseEntry pointExample plugin: creates or removes
Activationregister_activation_hook()creates table, schema version, capability, cron event
Updateno activation hook; version check on plugins_loadedsame setup routine, plus data migration
Deactivationregister_deactivation_hook()removes cron event and transient, keeps everything else
Uninstalluninstall.phpalways removes capability, cron and cache; table, options and user meta only after opt-in
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
Example plugin LW Search Log: activation sets up, deactivation stops, uninstall deletes data only after opt-in

Activation is not an update hook

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.

The current source confirms this. Before an update, Plugin_Upgrader::deactivate_plugin_before_upgrade() deactivates an active plugin with deactivate_plugins( $plugin, true ), and the second argument means silent, so no deactivation hook runs. During background updates via cron the plugin is not deactivated at all. Afterwards, wp-admin/update.php reactivates the plugin with activate_plugin( $plugin, '', ! empty( $_GET['networkwide'] ), true ), again silently. The docblock of activate_plugin() confirms that silent activation skips the activation hooks.

upgrader_process_complete runs within the request performing the update. If the previous plugin version was already loaded, that request can still run the old code. This hook alone is therefore not a reliable starting point for a new migration. The check on plugins_loaded compares the stored version with the loaded code. An older database version starts setup again.

Custom table with dbDelta and a versioned schema

Whether a separate table is worth it is covered in the article on the performance limits of wp_postmeta; here it is a given. dbDelta() lives in wp-admin/includes/upgrade.php, which has to be included explicitly. The function compares a CREATE TABLE 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:

  • each field on its own line;
  • two spaces between PRIMARY KEY and the definition of the key;
  • KEY instead of its synonym INDEX, and at least one KEY;
  • no apostrophes or backticks around field names;
  • field types in lower case, SQL keywords such as CREATE TABLE in upper case;
  • a length for every field type that accepts one, for example int(11).

dbDelta() is not a complete migration system. Old columns are not removed; a new column name initially creates another column. A message describing a planned change does not prove that the database operation succeeded. The example therefore checks the required column names afterwards. This does not fully check types or indexes. Renaming, removing and transforming data require separate steps. $wpdb->get_charset_collate() supplies the character set and collation settings.

The following main file logs searches on the website. The report under Tools is available to accounts with the lwsl_view_log permission. A daily WP-Cron task removes entries older than 90 days when the task actually runs. WP-Cron depends on a request triggering due work; without that trigger, entries remain longer. The code therefore does not guarantee deletion exactly after 90 days.

PHP
<?php
/**
 * Plugin Name:       LW Search Log
 * Description:       Stores front-end search terms in a custom table and prunes old rows once a day.
 * Version:           1.1.0
 * Requires at least: 6.5
 * Requires PHP:      7.4
 * Author:            Lukas Wojcik
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       lw-search-log
 *
 * @package LW_Search_Log
 */

defined( 'ABSPATH' ) || exit;

const LWSL_DB_VERSION = 2;
const LWSL_CRON_HOOK  = 'lwsl_prune_log';
const LWSL_CAP        = 'lwsl_view_log';

/**
 * Returns the log table name of the current site.
 *
 * @return string
 */
function lwsl_table() {
	global $wpdb;
	return $wpdb->prefix . 'lwsl_searches';
}

/**
 * Creates the log table or brings it to the current structure.
 */
function lwsl_create_table() {
	global $wpdb;

	require_once ABSPATH . 'wp-admin/includes/upgrade.php';

	$table   = lwsl_table();
	$collate = $wpdb->get_charset_collate();

	// dbDelta() parses this text: one column per line, two spaces after
	// PRIMARY KEY, KEY instead of INDEX, no backticks around names.
	$sql = "CREATE TABLE {$table} (
	id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
	term varchar(190) NOT NULL DEFAULT '',
	result_count int(11) unsigned NOT NULL DEFAULT 0,
	searched_at datetime NOT NULL,
	PRIMARY KEY  (id),
	KEY searched_at (searched_at)
) {$collate};";

	dbDelta( $sql );
	// Verify the structure, not dbDelta()'s descriptive return messages.
	$columns = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) );
	if ( $wpdb->last_error || array_diff( array( 'id', 'term', 'result_count', 'searched_at' ), (array) $columns ) ) {
		return false;
	}
	return true;
}

/**
 * Data migration for schema version 2: terms are stored in lower case.
 * The new column result_count is added by dbDelta() itself.
 */
function lwsl_migrate_to_2() {
	global $wpdb;
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	return false !== $wpdb->query( $wpdb->prepare( 'UPDATE %i SET term = LOWER(term)', lwsl_table() ) );
}

/**
 * Brings the current site to the state this code expects.
 * Idempotent: used on activation, after updates and for new sites.
 */
function lwsl_setup_site() {
	global $wpdb;
	// Advisory lock on this database connection; no waiting in a web request.
	$lock = 'lwsl:' . md5( DB_NAME . ':' . $wpdb->prefix );
	if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 0)', $lock ) ) ) {
		return false;
	}
	try {
		$installed = (int) get_option( 'lwsl_db_version', 0 );
		if ( $installed > LWSL_DB_VERSION || ! lwsl_create_table() ) {
			return false;
		}
		if ( $installed > 0 && $installed < 2 && ! lwsl_migrate_to_2() ) {
			return false;
		}
		$role = get_role( 'administrator' );
		if ( ! $role ) {
			return false;
		}
		if ( ! $role->has_cap( LWSL_CAP ) ) {
			$role->add_cap( LWSL_CAP );
			if ( $wpdb->last_error ) {
				return false;
			}
		}
		if ( ! wp_next_scheduled( LWSL_CRON_HOOK ) ) {
			$result = wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', LWSL_CRON_HOOK, array(), true );
			if ( is_wp_error( $result ) || ! $result ) {
				return false;
			}
		}
		update_option( 'lwsl_db_version', LWSL_DB_VERSION, true );
		return LWSL_DB_VERSION === (int) get_option( 'lwsl_db_version', 0 );
	} finally {
		$wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) );
	}
}

/**
 * Runs a callback once for every site of the network, 100 sites per query.
 *
 * @param callable $callback Function without parameters.
 */
function lwsl_for_each_site( $callback ) {
	$offset = 0;
	do {
		$site_ids = get_sites(
			array(
				'network_id' => get_current_network_id(),
				'fields' => 'ids',
				'number' => 100,
				'offset' => $offset,
			)
		);
		foreach ( $site_ids as $site_id ) {
			switch_to_blog( $site_id );
			try {
				call_user_func( $callback );
			} finally {
				restore_current_blog();
			}
		}
		$offset += 100;
	} while ( 100 === count( $site_ids ) );
}

/**
 * Activation: set up the current site, or every site on network activation.
 *
 * @param bool $network_wide True when the plugin is network-activated.
 */
function lwsl_activate( $network_wide = false ) {
	if ( is_multisite() && $network_wide ) {
		// On large networks the sites are set up lazily by lwsl_maybe_upgrade().
		if ( ! wp_is_large_network() ) {
			lwsl_for_each_site( 'lwsl_setup_site' );
		}
		return;
	}
	lwsl_setup_site();
}
register_activation_hook( __FILE__, 'lwsl_activate' );

/**
 * Update path: activation hooks do not run on updates, so the stored
 * schema version is compared with the code on every request.
 */
function lwsl_maybe_upgrade() {
	if ( (int) get_option( 'lwsl_db_version', 0 ) < LWSL_DB_VERSION ) {
		lwsl_setup_site();
	}
}
add_action( 'plugins_loaded', 'lwsl_maybe_upgrade' );

/**
 * Stops scheduled work and drops caches on the current site. Keeps all data.
 */
function lwsl_teardown_site() {
	wp_clear_scheduled_hook( LWSL_CRON_HOOK );
	delete_transient( 'lwsl_top_terms' );
}

/**
 * Deactivation: current site, or every site on network deactivation.
 *
 * @param bool $network_deactivating True when the plugin is network-deactivated.
 */
function lwsl_deactivate( $network_deactivating = false ) {
	if ( is_multisite() && $network_deactivating ) {
		lwsl_for_each_site( 'lwsl_teardown_site' );
		return;
	}
	lwsl_teardown_site();
}
register_deactivation_hook( __FILE__, 'lwsl_deactivate' );

/**
 * Cron callback: deletes log rows older than 90 days.
 */
function lwsl_prune_log() {
	global $wpdb;
	if ( LWSL_DB_VERSION !== (int) get_option( 'lwsl_db_version', 0 ) ) {
		return;
	}
	$cutoff = gmdate( 'Y-m-d H:i:s', time() - 90 * DAY_IN_SECONDS );
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	$wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE searched_at < %s', lwsl_table(), $cutoff ) );
	delete_transient( 'lwsl_top_terms' );
}
add_action( LWSL_CRON_HOOK, 'lwsl_prune_log' );

/**
 * Logs the search term of the main front-end query.
 */
function lwsl_log_search() {
	global $wpdb, $wp_query;

	if ( LWSL_DB_VERSION !== (int) get_option( 'lwsl_db_version', 0 ) || ! is_search() || is_paged() ) {
		return;
	}

	$term = mb_substr( sanitize_text_field( get_search_query( false ) ), 0, 190 );
	if ( '' === $term ) {
		return;
	}

	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	$wpdb->query(
		$wpdb->prepare(
			'INSERT INTO %i (term, result_count, searched_at) VALUES (LOWER(%s), %d, %s)',
			lwsl_table(),
			$term,
			(int) $wp_query->found_posts,
			current_time( 'mysql', true )
		)
	);
}
add_action( 'wp', 'lwsl_log_search' );

/**
 * Adds the log page under Tools, visible with the plugin capability only.
 */
function lwsl_admin_menu() {
	add_management_page(
		__( 'Search log', 'lw-search-log' ),
		__( 'Search log', 'lw-search-log' ),
		LWSL_CAP,
		'lwsl-search-log',
		'lwsl_render_page'
	);
}
add_action( 'admin_menu', 'lwsl_admin_menu' );

/**
 * Renders the top terms (cached in a transient) and the searches since the
 * current user's last visit (stored in user meta).
 */
function lwsl_render_page() {
	global $wpdb;
	if ( ! current_user_can( LWSL_CAP ) ) {
		wp_die( esc_html__( 'Access denied.', 'lw-search-log' ) );
	}
	if ( LWSL_DB_VERSION !== (int) get_option( 'lwsl_db_version', 0 ) ) {
		wp_die( esc_html__( 'Search log setup is incomplete.', 'lw-search-log' ) );
	}

	$user_id   = get_current_user_id();
	$last_seen = (int) get_user_meta( $user_id, $wpdb->prefix . 'lwsl_last_viewed', true );
	update_user_meta( $user_id, $wpdb->prefix . 'lwsl_last_viewed', time() );

	$top = get_transient( 'lwsl_top_terms' );
	if ( false === $top ) {
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- cached in a transient.
		$top = $wpdb->get_results(
			$wpdb->prepare( 'SELECT term, COUNT(*) AS hits FROM %i GROUP BY term ORDER BY hits DESC LIMIT 20', lwsl_table() )
		);
		set_transient( 'lwsl_top_terms', $top, HOUR_IN_SECONDS );
	}

	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	$new = (int) $wpdb->get_var(
		$wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE searched_at > %s', lwsl_table(), gmdate( 'Y-m-d H:i:s', $last_seen ) )
	);

	echo '<div class="wrap"><h1>' . esc_html__( 'Search log', 'lw-search-log' ) . '</h1><p>';
	echo esc_html(
		sprintf(
			/* translators: %d: number of searches since the last visit */
			__( 'New searches since the last visit: %d', 'lw-search-log' ),
			$new
		)
	);
	echo '</p><ol>';
	foreach ( $top as $row ) {
		printf( '<li>%s (%d)</li>', esc_html( $row->term ), (int) $row->hits );
	}
	echo '</ol></div>';
}

/**
 * Multisite: drop the log table together with a deleted site.
 *
 * @param string[] $tables  Tables WordPress is about to drop.
 * @param int      $site_id ID of the deleted site.
 * @return string[]
 */
function lwsl_drop_site_table( $tables, $site_id ) {
	global $wpdb;
	$tables[] = $wpdb->get_blog_prefix( $site_id ) . 'lwsl_searches';
	return $tables;
}
add_filter( 'wpmu_drop_tables', 'lwsl_drop_site_table', 10, 2 );

lwsl_setup_site() can run again sequentially without deliberately creating extra tables or scheduled events. This is the meaning of “idempotent”; concurrent calls still need separate protection. A named database lock limits setup to one call per site on the database connection. Missing columns, a failed data migration or a failed Cron schedule prevent version 2 from being recorded. A later request retries setup. Logging and reporting stay disabled until the expected version is present. Version 2 adds result_count and converts existing terms to lower case when upgrading from version 1.

lwsl_db_version is read frequently. Autoload fetches this small option together with other frequently used options. Without autoload, another database query is possible, but an object cache can avoid a query on every page view. add_cap() passes the role option to update_option(); an unchanged value is not written again. The %i placeholder in $wpdb->prepare() handles table names as database identifiers and has been available since WordPress 6.2.

Deactivation: stop, but do not delete

Deactivation during troubleshooting is often temporary. The example therefore removes the scheduled Cron event and the cached ranking. WordPress calls this kind of temporary cache a transient. The table, schema version, permission and last report visit time remain available.

wp_clear_scheduled_hook() removes events for a hook with exactly the specified arguments. wp_unschedule_hook() removes all events for that hook, including events with different arguments. The example schedules without arguments, so the first function is sufficient during deactivation. Uninstall uses the second function to remove any differently configured events for this plugin hook as well.

The deactivation function receives true for network deactivation. The loop then processes only sites belonging to the current network. The permission stays in place for a later activation. The report itself is unavailable while the plugin is inactive.

Uninstall: uninstall.php, register_uninstall_hook and the opt-in

During normal deletion, delete_plugins() first calls uninstall_plugin(). An existing uninstall.php takes precedence over a registered callback. WordPress defines WP_UNINSTALL_PLUGIN and loads that file without loading the main file for this purpose. The uninstall file therefore spells out the required names. Without uninstall.php, WordPress loads the main file and calls the stored callback; code outside functions runs as well.

The WP_UNINSTALL_PLUGIN check prevents the intended cleanup path from running through a direct file request. A callback registered with register_uninstall_hook() is stored in an option. A closure, meaning an anonymous function, cannot be serialized for that storage and causes an error. A method on a specific object is rejected too. A function name or static class method is suitable. Registration belongs in activation. The following calls show alternatives and failure cases, not a setup block intended to run in full.

JavaScript
// Counter-example: what register_uninstall_hook() accepts and what it rejects.

// Accepted: a function name or a static method. The callback is stored
// in the option uninstall_plugins, so register it once, on activation.
register_uninstall_hook( __FILE__, 'lwsl_uninstall' );
register_uninstall_hook( __FILE__, array( 'LWSL_Plugin', 'uninstall' ) );

// Rejected: a closure cannot be serialized into that option (fatal error),
// an object method triggers _doing_it_wrong() and is not registered.
register_uninstall_hook( __FILE__, function () {} );
register_uninstall_hook( __FILE__, array( new LWSL_Plugin(), 'uninstall' ) );

Removing plugin files does not necessarily mean abandoning the stored content: a fresh installation can reuse the data. The routine therefore removes Cron events, caches and role permissions regardless of the deletion setting. Log tables and related settings are removed only when the setting equals 1. In multisite, this decision belongs to each network. If dropping a table fails, the routine stops before removing that site’s schema version.

PHP
<?php
/**
 * Uninstall routine of LW Search Log.
 *
 * WordPress includes this file when the plugin is deleted. The main plugin
 * file is not loaded, so every name is spelled out here.
 *
 * @package LW_Search_Log
 */

// Only run when WordPress itself starts the uninstall.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

/**
 * Cleans one site: technical leftovers always, data only after opt-in.
 *
 * @param bool $delete_data Whether the site owner opted in to data removal.
 */
function lwsl_uninstall_site( $delete_data ) {
	global $wpdb;

	// Always: scheduled events, caches and the capability.
	wp_unschedule_hook( 'lwsl_prune_log' );
	delete_transient( 'lwsl_top_terms' );

	foreach ( wp_roles()->role_objects as $role ) {
		if ( $role->has_cap( 'lwsl_view_log' ) ) {
			$role->remove_cap( 'lwsl_view_log' );
		}
	}

	if ( ! $delete_data ) {
		return;
	}

	// Opt-in only: the log table and the schema version.
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange
	$result = $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'lwsl_searches' ) );
	if ( false === $result ) {
		throw new RuntimeException( 'Search log table could not be removed.' );
	}
	delete_option( 'lwsl_db_version' );
	delete_metadata( 'user', 0, $wpdb->prefix . 'lwsl_last_viewed', '', true );
}

// Files are shared across networks, but each network owns its opt-in.
// Accept the documented value 1 only; a string such as "false" is not consent.
if ( is_multisite() ) {
	$offset = 0;
	do {
		$sites = get_sites( array( 'number' => 100, 'offset' => $offset ) );
		foreach ( $sites as $site ) {
			$delete_data = '1' === (string) get_network_option( (int) $site->network_id, 'lwsl_delete_data_on_uninstall', false );
			switch_to_blog( (int) $site->blog_id );
			try {
				lwsl_uninstall_site( $delete_data );
			} finally {
				restore_current_blog();
			}
		}
		$offset += 100;
	} while ( 100 === count( $sites ) );
	// The consent options stay until all sites have been processed successfully.
	foreach ( get_networks( array( 'number' => 0 ) ) as $network ) {
		if ( '1' === (string) get_network_option( (int) $network->id, 'lwsl_delete_data_on_uninstall', false ) ) {
			delete_network_option( (int) $network->id, 'lwsl_delete_data_on_uninstall' );
		}
	}
} else {
	$delete_data = '1' === (string) get_option( 'lwsl_delete_data_on_uninstall', false );
	lwsl_uninstall_site( $delete_data );
	if ( $delete_data ) {
		delete_option( 'lwsl_delete_data_on_uninstall' );
	}
}
// Legacy unprefixed lwsl_last_viewed metadata is deliberately not deleted:
// its ownership across sites is ambiguous and requires a separate cleanup plan.

The last report visit now uses a key with the site prefix, such as wp_2_lwsl_last_viewed. Opening a report on one site therefore does not change another site’s counter. delete_metadata() with $delete_all = true removes that exact key for every account. Values under the earlier shared key remain because ownership cannot be determined reliably. delete_transient() also handles an external object cache; direct deletion from wp_options would not clear that cache.

The following commands delete data and belong only in a disposable test installation. How the switch is set is a matter for the settings page; a separate article in this series covers the Settings API. For a test, WP-CLI is enough. wp plugin uninstall with --skip-delete runs only the uninstall procedure and keeps the files, while wp plugin delete removes the files without deactivating or uninstalling. The article on WP-CLI delete commands and the tables they touch looks at that difference in more detail.

Shell
# Single site: opt in, then run only the uninstall routine (files stay).
wp option update lwsl_delete_data_on_uninstall 1
wp plugin deactivate lw-search-log
wp plugin uninstall lw-search-log --skip-delete

# Multisite: the switch is a network option.
wp site option update lwsl_delete_data_on_uninstall 1
wp plugin deactivate lw-search-log --network
wp plugin uninstall lw-search-log --skip-delete

# Deletes the files only: neither deactivation nor uninstall code runs.
wp plugin delete lw-search-log

Multisite: once per network, but data per site

The example keeps tables, options, Cron events and roles separately for each site. User metadata resides in a shared table. Including the site prefix in the metadata key separates report visit times. get_network_option() reads the deletion setting for the relevant network; a single-site installation uses get_option(). Consent from one network therefore does not decide whether another network’s data is removed.

Network activation calls the activation hook once, not once per site. The custom loop reads up to 100 site IDs from the current network at a time. switch_to_blog() changes the site context but does not load another plugin version. finally restores the previous context even after an exception. Uninstall, in contrast, covers all networks because the plugin files are shared.

Very large networks may exceed the time available for a single activation request. Without an argument, wp_is_large_network() checks the site count: the default threshold is more than 10,000 sites. Counting users requires the corresponding argument; filters can change the decision. For a large network, the example postpones setup until each site receives a request with the plugin active. This also covers sites created later. Immediate setup of new sites could be added on wp_initialize_site after core initialization.

The wpmu_drop_tables filter adds the plugin table when a site is deleted. This works only when the plugin code is loaded. With the plugin inactive, the table can remain. A later uninstall cannot find an already deleted site through get_sites(), so those leftovers need separate, verified cleanup. Uninstall across many existing sites can also time out; this example does not include a resumable job queue.

Limits and open points

The historical report records tests of the original version on WordPress 7.1.2, PHP 8.4 and MariaDB 11.8, both on a single site and a subdirectory network. Tests covered activation, schema changes, new and deleted sites, and uninstall with and without consent. Plugin Check reported only a missing readme.txt at that time. The revised version here passed PHP syntax checks and twelve isolated tests including failure cases using substitute WordPress functions. These checks do not establish a new full WordPress, multi-network or real concurrency test of the revised code.

  • GET_LOCK() applies on the database server handling the request and belongs to the connection. A lost connection or a different backend behind a proxy can invalidate this protection. The lock is not a transaction and does not undo completed changes. The actual environment needs verification before production use.
  • The handbook requires a length for integer types such as int(11). On MariaDB 11.8, repeated dbDelta() calls with int(11) reported no change; without a length they reported “Changed type” on every call, because MariaDB shows int(10). For MySQL 8.0.17 and later, core has ignored pure width differences since WordPress 6.1 (according to the source, not measured).
  • Capabilities granted directly to individual users are stored in user meta and are not removed by the loop over roles.
  • The revised uninstall reads consent separately for each network and removes visit times only under the corresponding site prefix. Earlier shared metadata keys and permissions granted directly to individual accounts need separate cleanup. A later failure does not undo steps already completed.
  • Simply removing files through FTP, Git or a deployment process does not start uninstall.php. A process intended to remove database content needs an additional, previously verified cleanup step.

Questions and answers

Does uninstall.php run when a plugin is removed via FTP or with wp plugin delete?

No. FTP and wp plugin delete remove files without running the uninstall routine. wp plugin uninstall runs the routine; --skip-delete keeps the files afterwards. A Git or deployment process only cleans database content if a separate cleanup step has been configured.

What happens if a plugin uses both uninstall.php and register_uninstall_hook()?

uninstall.php takes precedence. In that path, WordPress removes the registered callback from the uninstall_plugins option and loads the file. The other callback is not run in addition.

Why does the version check run on plugins_loaded and not on admin_init?

admin_init runs for administration screens, admin-ajax.php and admin-post.php, but not for a normal front-end request. After replacing plugin files, the front end could therefore encounter the old table structure. plugins_loaded checks the version earlier in requests that load the plugin. This example also blocks use of the table until the expected version is present.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in touch

A question or an idea for a web project? Tell me a little about it. This form uses reCAPTCHA to protect against spam.

Write a comment

Experience with other plugins or hosting environments and questions about the setup are welcome here.

The email address is not published. Required fields are marked with an asterisk.