LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Przegląd bloga /Wtyczki i triki WordPress / Cykl życia wtyczki: aktywacja, dezaktywacja, odinstalowanie
Ten artykuł w innych językach:

Cykl życia wtyczki: aktywacja, dezaktywacja, odinstalowanie

Cykl życia wtyczki: aktywacja, dezaktywacja, odinstalowanie
Spis treści
  1. Trzy fazy, trzy punkty wejścia
  2. Aktywacja to nie hook aktualizacji
  3. Własna tabela z dbDelta i wersjonowanym schematem
  4. Dezaktywacja: zatrzymać, nie kasować
  5. Odinstalowanie: uninstall.php, register_uninstall_hook i zgoda
  6. Multisite: raz na sieć, dane na każdej witrynie
  7. Ograniczenia i otwarte kwestie
  8. Pytania i odpowiedzi
  9. Źródła

Wiele wtyczek zostawia ślady długo po tym, jak przestały robić cokolwiek pożytecznego: tabelę, której nikt już nie czyta, zdarzenia cron bez callbacka, który mógłby je obsłużyć, czy uprawnienie (capability) przy roli administratora, które nie reguluje już dostępu do żadnego ekranu. WordPress udostępnia trzy osobne punkty wejścia dla aktywacji, dezaktywacji i odinstalowania. Warto znać ich mniej oczywiste zachowania: hook aktywacji nie uruchamia się przy aktualizacjach, aktywacja sieciowa w Multisite wywołuje go dokładnie raz zamiast raz na każdą witrynę, a procedura odinstalowania zarejestrowana jako closure kończy się błędem krytycznym.

Artykuł prowadzi małą, ale kompletną wtyczkę przez wszystkie trzy fazy. Wtyczka zakłada własną tabelę za pomocą dbDelta(), zapisuje wersję schematu i migruje dane przy aktualizacjach, planuje codzienne zdarzenie cron, nadaje administratorom własną capability i usuwa to wszystko przez plik uninstall.php, który kasuje dane wyłącznie po wyraźnej zgodzie. Każde stwierdzenie o zachowaniu rdzenia zostało sprawdzone w podręczniku, w dokumentacji funkcji i w kodzie źródłowym WordPressa 7.1.2, aktualnej wersji na dzień 29.09.2026.

Trzy fazy, trzy punkty wejścia

Podręcznik dla twórców wtyczek wyraźnie rozdziela fazy: aktywacja służy do reguł przepisywania adresów, własnych tabel i domyślnych opcji, dezaktywacja usuwa dane tymczasowe, takie jak cache, a dopiero odinstalowanie powinno kasować dane trwałe, czyli opcje i tabele.

Od strony technicznej register_activation_hook() i register_deactivation_hook() to cienkie nakładki. Obie funkcje przepuszczają przekazany plik przez plugin_basename() i podpinają callback pod dynamiczną akcję activate_{$plugin} lub deactivate_{$plugin}. Pierwszym argumentem musi więc być główny plik wtyczki z nagłówkiem, zwykle __FILE__ w tym właśnie pliku. Odinstalowanie działa inaczej: WordPress albo dołącza plik uninstall.php z folderu wtyczki, albo wywołuje callback zarejestrowany przez register_uninstall_hook(). Oba warianty działają tylko przy usuwaniu wtyczki, nie przy jej dezaktywacji.

Faza Punkt wejścia Przykładowa wtyczka: zakłada lub usuwa
Aktywacja register_activation_hook() zakłada tabelę, wersję schematu, capability i zdarzenie cron
Aktualizacja brak hooka, kontrola wersji na plugins_loaded ta sama procedura konfiguracji plus migracja danych
Dezaktywacja register_deactivation_hook() usuwa zdarzenie cron i transient, resztę zostawia
Odinstalowanie uninstall.php zawsze usuwa capability, cron i cache; tabelę, opcje i meta użytkowników tylko po zgodzie
Cykl życia przykładowej wtyczki LW Search Log: aktywacja zakłada tabelę, opcję wersji, capability i codzienny cron; dezaktywacja usuwa tylko zdarzenie cron i transient; odinstalowanie zawsze usuwa capability i cron, a tabelę i meta użytkowników tylko po zgodzie; aktualizacja powtarza konfigurację
Przykładowa wtyczka LW Search Log: aktywacja konfiguruje, dezaktywacja zatrzymuje, odinstalowanie kasuje dane tylko po zgodzie

Aktywacja to nie hook aktualizacji

Od WordPressa 3.1 hook aktywacji nie uruchamia się przy aktualizacjach. Andrew Nacin uzasadnił tę zmianę w październiku 2010 roku na blogu Make WordPress Core: wcześniej hook działał przy pojedynczych aktualizacjach, przy zbiorczych już nie, a ręczne aktualizacje przez FTP lub SVN nigdy go nie wywoływały. Zalecenie z tego samego wpisu pozostaje aktualne: wersję należy zapisać w bazie danych, porównać z wersją w kodzie i przy rozbieżności uruchomić procedurę aktualizacji.

Obecny kod źródłowy to potwierdza. Przed aktualizacją Plugin_Upgrader::deactivate_plugin_before_upgrade() dezaktywuje aktywną wtyczkę wywołaniem deactivate_plugins( $plugin, true ); drugi argument oznacza tryb cichy, więc żaden hook dezaktywacji się nie uruchamia. Przy aktualizacjach w tle przez cron wtyczka w ogóle nie jest dezaktywowana. Następnie wp-admin/update.php ponownie aktywuje wtyczkę wywołaniem activate_plugin( $plugin, '', ! empty( $_GET['networkwide'] ), true ), również po cichu. Docblock funkcji activate_plugin() stwierdza, że cicha aktywacja pomija hooki aktywacji.

Hook upgrader_process_complete słabo nadaje się do migracji. Uruchamia się w żądaniu, które przeprowadziło aktualizację, a notatka w dokumentacji hooka zwraca uwagę, że w tym momencie działa jeszcze stary kod wtyczki. Migracja napisana dla wersji 2 zadziałałaby więc dopiero o jedną aktualizację później. Kontrola wersji na plugins_loaded, pokazana w rozdziale podręcznika o własnych tabelach, działa natomiast przy każdym żądaniu już z nowym kodem, niezależnie od tego, jaką drogą dotarła aktualizacja.

Własna tabela z dbDelta i wersjonowanym schematem

To, czy własna tabela w ogóle się opłaca, omawia artykuł o granicach wydajności wp_postmeta; tutaj tabela jest założeniem. dbDelta() znajduje się w pliku wp-admin/includes/upgrade.php, który trzeba dołączyć jawnie. Funkcja porównuje instrukcję CREATE TABLE z istniejącą tabelą i uzupełnia braki. Ponieważ analizuje SQL jako tekst, jest bardzo wymagająca co do formatu. Podręcznik wymaga:

  • każdego pola w osobnym wierszu;
  • dwóch spacji między PRIMARY KEY a definicją klucza;
  • słowa KEY zamiast synonimu INDEX i co najmniej jednego KEY;
  • braku apostrofów i grawisów wokół nazw pól;
  • typów pól zapisanych małymi literami, a słów kluczowych SQL, takich jak CREATE TABLE, wielkimi;
  • długości przy każdym typie pola, który ją przyjmuje, na przykład int(11).

Dokumentacja funkcji dodaje ograniczenia: dbDelta() nie usuwa kolumn, pole o zmienionej nazwie trafia do tabeli jako dodatkowa kolumna, a zwracane komunikaty nie dowodzą, że zmiana rzeczywiście została wykonana. Zmiana nazw, usuwanie kolumn i przekształcanie danych należą więc do osobnych kroków migracji. Zestaw znaków dostarcza $wpdb->get_charset_collate().

Poniżej pełny plik główny przykładowej wtyczki. Rejestruje ona wyszukiwania z frontendu w tabeli, raz dziennie usuwa wiersze starsze niż 90 dni i w menu „Narzędzia” pokazuje najczęstsze frazy wszystkim, którzy mają capability lwsl_view_log.

<?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 );
}

/**
 * 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
	$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() {
	$installed = (int) get_option( 'lwsl_db_version', 0 );

	lwsl_create_table();

	if ( $installed > 0 && $installed < 2 ) {
		lwsl_migrate_to_2();
	}

	$role = get_role( 'administrator' );
	if ( $role && ! $role->has_cap( LWSL_CAP ) ) {
		$role->add_cap( LWSL_CAP );
	}

	if ( ! wp_next_scheduled( LWSL_CRON_HOOK ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', LWSL_CRON_HOOK );
	}

	// Read on every request by lwsl_maybe_upgrade(), so it stays autoloaded.
	update_option( 'lwsl_db_version', LWSL_DB_VERSION, true );
}

/**
 * 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(
				'fields' => 'ids',
				'number' => 100,
				'offset' => $offset,
			)
		);
		foreach ( $site_ids as $site_id ) {
			switch_to_blog( $site_id );
			call_user_func( $callback );
			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;
	$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 ( ! 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;

	$user_id   = get_current_user_id();
	$last_seen = (int) get_user_meta( $user_id, 'lwsl_last_viewed', true );
	update_user_meta( $user_id, '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 );

Sercem wtyczki jest lwsl_setup_site(). Funkcja jest idempotentna: dbDelta() niczego nie zmienia, gdy tabela już pasuje, capability zapisuje się tylko wtedy, gdy jej brakuje, a zdarzenie cron zostaje zaplanowane tylko wtedy, gdy wp_next_scheduled() żadnego nie znajdzie. Dzięki temu da się ją bezpiecznie wywołać z hooka aktywacji, z kontroli wersji i dla jeszcze nieskonfigurowanych witryn w sieci. Wersja schematu 2 pokazuje oba rodzaje migracji. Nową kolumnę result_count dbDelta() dodaje samodzielnie, natomiast zamiana istniejących fraz na małe litery to migracja danych z własnym krokiem, który uruchamia się tylko przy zapisanej wersji 1.

Dwa szczegóły łatwo przeoczyć. Opcja lwsl_db_version jest odczytywana przy każdym żądaniu, dlatego zapisuje się ją z włączonym autoload; w przeciwnym razie każde wyświetlenie strony kosztowałoby dodatkowe zapytanie. Przy każdym wywołaniu add_cap() przekazuje całą opcję ról do update_option(). Ta funkcja zapisuje ją w bazie tylko wtedy, gdy capability jest nowa. Mimo to przykład dodaje uprawnienie wyłącznie w procedurze konfiguracji, a nie na init. Nazwy tabel trafiają do zapytań przez symbol zastępczy %i, który $wpdb->prepare() obsługuje dla identyfikatorów od WordPressa 6.2.

Dezaktywacja: zatrzymać, nie kasować

Dezaktywacja bywa tymczasowa, na przykład podczas szukania błędu. Przykład usuwa więc tylko to, co bez wtyczki działałoby dalej albo po ponownej aktywacji byłoby nieaktualne: zdarzenie cron i zapisaną w transiencie listę najczęstszych fraz. Tabela, wersja schematu, capability i meta użytkowników zostają.

Do obsługi crona służą dwie funkcje. wp_clear_scheduled_hook() usuwa wszystkie zdarzenia hooka z podanymi argumentami, a dokumentacja podkreśla, że argumenty muszą się dokładnie zgadzać. Jeśli wtyczka planuje ten sam hook z różnymi argumentami, pomoże tylko wp_unschedule_hook(): funkcja istnieje od wersji 4.9, usuwa wszystkie zdarzenia hooka bez względu na argumenty, a jej docblock wprost wymienia sprzątanie kolejki cron przy dezaktywacji jako zastosowanie. Przykład planuje zdarzenie bez argumentów; procedura odinstalowania mimo to sięga dla pewności po drugą funkcję.

Callback dezaktywacji otrzymuje wartość logiczną, która w Multisite jest prawdziwa przy dezaktywacji sieciowej. lwsl_deactivate() sprząta wtedy na wszystkich witrynach. Capability przy dezaktywacji celowo zostaje: bez wtyczki żaden kod jej nie sprawdza, a usunięcie i ponowne dodanie oznaczałoby dwa zbędne zapisy opcji ról na każdej witrynie.

Odinstalowanie: uninstall.php, register_uninstall_hook i zgoda

Przy usuwaniu wtyczki delete_plugins() wywołuje dla każdej z nich uninstall_plugin(), zanim znikną pliki. Kod źródłowy rozstrzyga też pierwszeństwo między wariantami: jeśli istnieje uninstall.php, WordPress usuwa ewentualnie zarejestrowany callback z opcji uninstall_plugins, definiuje stałą WP_UNINSTALL_PLUGIN z nazwą bazową wtyczki i dołącza wyłącznie ten plik. Główny plik wtyczki nie zostaje przy tym załadowany. Dlatego plik pokazany niżej wypisuje każdą nazwę w całości, zamiast korzystać ze stałych takich jak LWSL_CAP. Dopiero gdy uninstall.php nie istnieje, WordPress dołącza plik główny i wykonuje zapisany callback na uninstall_{$file}; wtedy uruchamia się również każdy wiersz kodu na poziomie pliku.

Sprawdzenie stałej WP_UNINSTALL_PLUGIN na początku pliku chroni przed bezpośrednim wywołaniem, czego wymaga podręcznik. Dla register_uninstall_hook() obowiązują ostrzejsze reguły niż dla dwóch pozostałych funkcji rejestrujących, bo callback trafia do opcji w bazie. Closure nie da się zserializować, co kończy się błędem krytycznym; metoda obiektu zostaje odrzucona z komunikatem _doing_it_wrong(). Dozwolone są tylko nazwy funkcji i metody statyczne. Każde wywołanie odczytuje opcję i zapisuje ją, gdy brakuje wpisu albo różni się on od podanego; rejestracja należy więc do aktywacji, a nie do każdego żądania.

// 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' ) );

Co oznacza „usuwanie danych przy odinstalowaniu” jako zgoda typu opt-in? Usunięcie wtyczki nie musi oznaczać pożegnania z jej danymi; także ponowna instalacja, która ma wykluczyć uszkodzone pliki, zaczyna się od usunięcia. Przykład dzieli więc sprzątanie na dwie grupy. Pozostałości techniczne znikają zawsze: zdarzenia cron, transienty i capability są bez wtyczki bezużyteczne, a aktywacja i tak tworzy je na nowo. Treści i konfiguracja znikają tylko po wyraźnym „tak”: tabela dziennika, wersja schematu, meta użytkowników i sam przełącznik. Bez zgody ponowna instalacja zastaje stary dziennik razem z wersją schematu.

<?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
	$wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'lwsl_searches' ) );
	delete_option( 'lwsl_db_version' );
}

// The opt-in switch: a network option on multisite, a normal option otherwise.
$lwsl_delete_data = (bool) get_site_option( 'lwsl_delete_data_on_uninstall', false );

if ( is_multisite() ) {
	$lwsl_offset = 0;
	do {
		$lwsl_site_ids = get_sites(
			array(
				'fields' => 'ids',
				'number' => 100,
				'offset' => $lwsl_offset,
			)
		);
		foreach ( $lwsl_site_ids as $lwsl_site_id ) {
			switch_to_blog( $lwsl_site_id );
			lwsl_uninstall_site( $lwsl_delete_data );
			restore_current_blog();
		}
		$lwsl_offset += 100;
	} while ( 100 === count( $lwsl_site_ids ) );
} else {
	lwsl_uninstall_site( $lwsl_delete_data );
}

if ( $lwsl_delete_data ) {
	// wp_usermeta is shared by all sites: one call removes the key for every user.
	delete_metadata( 'user', 0, 'lwsl_last_viewed', '', true );
	delete_site_option( 'lwsl_delete_data_on_uninstall' );
}

Meta użytkowników usuwa delete_metadata() z piątym argumentem $delete_all ustawionym na true, co kasuje klucz u wszystkich użytkowników naraz. delete_user_meta() działa za każdym razem tylko dla jednego identyfikatora użytkownika. delete_transient() uwzględnia ponadto zewnętrzny cache obiektów i w takim przypadku usuwa wpis właśnie tam, a nie w tabeli opcji. Zapytanie LIKE na _transient_% w wp_options takiego cache nie obejmuje.

Sposób ustawiania przełącznika to zadanie strony ustawień; Settings API omawia osobny artykuł z tej serii. Do testu wystarczy WP-CLI. wp plugin uninstall z opcją --skip-delete wykonuje tylko procedurę odinstalowania i zostawia pliki, a wp plugin delete usuwa pliki bez dezaktywacji i bez odinstalowania. Tę różnicę dokładniej opisuje artykuł o poleceniach usuwania w WP-CLI i tabelach, których dotykają.

# 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: raz na sieć, dane na każdej witrynie

W Multisite większość danych przykładu jest przechowywana osobno dla każdej witryny. Tabela ma prefiks witryny, każda witryna ma własne opcje i transienty, zdarzenia cron znajdują się w jej opcji cron, a role w opcji o nazwie złożonej z prefiksu witryny i user_roles. Metadane użytkowników są natomiast przechowywane w jednej tabeli dla całej sieci. Przełącznik zgody jest odczytywany przez get_site_option() jako opcja sieciowa; w pojedynczej instalacji jest to zwykła opcja.

Aktywacja sieciowa wywołuje hook aktywacji dokładnie raz, w kontekście bieżącej witryny i z $network_wide ustawionym na true. activate_plugin() ustawia tę wartość także wtedy, gdy nagłówek wtyczki zawiera Network: true. O wszystkie pozostałe witryny wtyczka musi zadbać sama. lwsl_for_each_site() robi to za pomocą get_sites() i switch_to_blog(). Czyhają tu dwie pułapki: get_sites() bez dodatkowych parametrów zwraca najwyżej 100 witryn, dlatego pętla jawnie stronicuje wyniki; a switch_to_blog() przełącza tylko kontekst bazy danych, nie załadowany kod. Tutaj nie ma to znaczenia, bo pętla wywołuje wyłącznie funkcje tej wtyczki.

W dużych sieciach pętla po wszystkich witrynach podczas aktywacji może trafić na limity czasu. WordPress uznaje sieć za dużą powyżej 10 000 witryn lub użytkowników, a wynik wp_is_large_network() da się filtrować. W takim przypadku przykład pomija pętlę i polega na kontroli wersji: lwsl_maybe_upgrade() działa przy każdym żądaniu każdej witryny, na której wtyczka jest aktywna, i konfiguruje witrynę przy jej pierwszym żądaniu. Obejmuje to również witryny utworzone po aktywacji. Kto chce konfigurować nowe witryny natychmiast, może podpiąć się pod wp_initialize_site; rdzeń wykonuje tam własną inicjalizację z priorytetem 10, więc callback wtyczki należy zarejestrować z późniejszym priorytetem.

Usunięcie pojedynczej witryny obsługuje filtr wpmu_drop_tables, który wymienia tabele usuwane przez WordPressa razem z witryną. Przykład dopisuje tam swoją tabelę niezależnie od zgody, bo i tak znika cała witryna. Usunięcie samej wtyczki jest w Multisite zarezerwowane dla superadministratorów, jak pokazuje mapowanie capability delete_plugins, a uninstall.php uruchamia się wtedy raz w panelu sieci. Pętla po wszystkich witrynach w procedurze odinstalowania jest więc obowiązkowa; podręcznik wprost ostrzega, że w dużych sieciach może pochłaniać dużo zasobów.

Ograniczenia i otwarte kwestie

Przykładowy kod przetestowano w WordPressie 7.1.2 (PHP 8.4, MariaDB 11.8) na pojedynczej instalacji i w sieci opartej na podkatalogach: aktywację, aktywację sieciową, witrynę utworzoną później, usunięcie witryny, aktualizację ze schematu 1 do 2, dezaktywację oraz odinstalowanie ze zgodą i bez niej. Plugin Check 2.1.0 nie zgłasza żadnych uwag do obu plików PHP.

  • Równoległe żądania tuż po aktualizacji mogą wykonać lwsl_setup_site() dwukrotnie. Tutaj jest to nieszkodliwe, bo każdy krok jest idempotentny; inne migracje wymagają blokady.
  • Podręcznik wymaga podawania długości także przy typach całkowitych, takich jak int(11). W MariaDB 11.8 powtarzane wywołania dbDelta() z int(11) nie zgłaszały zmian; bez podanej długości każde wywołanie zgłaszało „Changed type”, bo MariaDB pokazuje int(10). W MySQL od 8.0.17 rdzeń od WordPressa 6.1 pomija same różnice szerokości (według kodu źródłowego, bez pomiaru).
  • Capabilities przypisane bezpośrednio pojedynczym użytkownikom są zapisane w meta użytkowników i pętla po rolach ich nie usuwa.
  • W instalacjach z kilkoma sieciami get_sites() bez network_id zwraca witryny wszystkich sieci, a przełącznik zgody jest zapisany osobno dla każdej sieci. Właściwe zachowanie w takim przypadku pozostaje otwarte.
  • Usunięcie wtyczki przez FTP, Git lub skrypt wdrożeniowy całkowicie omija uninstall.php. Wtedy żadna wtyczka nie jest w stanie po sobie posprzątać.

Pytania i odpowiedzi

Czy uninstall.php działa także wtedy, gdy wtyczkę usunięto przez FTP lub poleceniem wp plugin delete?

Nie. WordPress wywołuje procedurę odinstalowania tylko z funkcji delete_plugins(), czyli przy usuwaniu wtyczki w panelu. WP-CLI oferuje do tego wp plugin uninstall; według dokumentacji wp plugin delete usuwa jedynie pliki, bez dezaktywacji i bez odinstalowania. Przy FTP, Git lub skryptach wdrożeniowych wszystko zostaje w bazie danych.

Co się dzieje, gdy wtyczka korzysta jednocześnie z uninstall.php i register_uninstall_hook()?

Wygrywa plik. uninstall_plugin() najpierw sprawdza, czy istnieje uninstall.php, następnie usuwa zarejestrowany callback z opcji uninstall_plugins i dołącza tylko ten plik. Callback nie zostaje wtedy nigdy wywołany.

Dlaczego kontrola wersji działa na plugins_loaded, a nie na admin_init?

Aktualizacje docierają nie tylko przez panel, lecz także przez WP-CLI, automatyczne aktualizacje w tle albo wgrane pliki. Na admin_init migracja uruchomiłaby się dopiero po otwarciu panelu; do tego czasu nowy kod działałby na frontendzie ze starym schematem. plugins_loaded działa przy każdym żądaniu, a podręcznik umieszcza kontrolę właśnie tam. Opcję z wersją schematu warto więc zapisywać z włączonym autoload.

Lukas Wójcik

Lukas Wójcik

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

Briefly describe your project or inquiry for a tailored response. This site is protected by reCAPTCHA.

Napisanie komentarza

Doświadczenia z innymi wtyczkami lub środowiskami hostingowymi oraz pytania o konfigurację są tu mile widziane.

Adres e-mail nie jest publikowany. Pola obowiązkowe oznaczono gwiazdką.

Artykuły i kategorie

CCTV

Wszystkie artykuły w tej kategorii (6) Śledź tę kategorię przez RSS

Cloud & AI

Wszystkie artykuły w tej kategorii (19) Śledź tę kategorię przez RSS

Data Privacy

Wszystkie artykuły w tej kategorii (20) Śledź tę kategorię przez RSS

Digital Analytics

Wszystkie artykuły w tej kategorii (62) Śledź tę kategorię przez RSS

Digital Marketing

Wszystkie artykuły w tej kategorii (39) Śledź tę kategorię przez RSS

IT & Networks

Wszystkie artykuły w tej kategorii (19) Śledź tę kategorię przez RSS

Music Production

Wszystkie artykuły w tej kategorii (18) Śledź tę kategorię przez RSS

Raspberry Pi

Wszystkie artykuły w tej kategorii (11) Śledź tę kategorię przez RSS

SaaS & Internet Earning

Śledź tę kategorię przez RSS

Smart Home

Wszystkie artykuły w tej kategorii (18) Śledź tę kategorię przez RSS

Tworzenie stron internetowych

Wszystkie artykuły w tej kategorii (11) Śledź tę kategorię przez RSS

Wtyczki i triki WordPress

Wszystkie artykuły w tej kategorii (16) Śledź tę kategorię przez RSS