Plugin-Lebenszyklus: Aktivierung, Deaktivierung, Deinstallation

Management Summary
Ein WordPress-Plugin braucht klare Regeln für Einrichtung, vorübergehendes Abschalten und endgültiges Entfernen. Diese Schritte bestimmen, welche Daten erhalten bleiben und welche Aufgaben weiterlaufen. Bei Fehlersuche oder Softwarewechseln schützt eine saubere Trennung vor unerwartetem Datenverlust und liegen gebliebenen Aufgaben. Das Beispiel bewahrt dauerhafte Daten beim Abschalten und löscht solche Daten erst nach ausdrücklicher Entscheidung. Aktualisierungen benötigen eine eigene Vorbereitung.
Inhalt
- Drei Phasen, drei Einstiegspunkte
- Die Aktivierung ist kein Update-Hook
- Eigene Tabelle mit dbDelta und versioniertem Schema
- Deaktivierung: anhalten, nicht löschen
- Deinstallation: uninstall.php, register_uninstall_hook und das Opt-in
- Multisite: einmal je Netzwerk, Daten je Website
- Grenzen und offene Punkte
- Fragen und Antworten
- Quellen
Ein deaktiviertes Plugin kann Tabellen, geplante Aufgaben und Zugriffsrechte zurücklassen. WordPress unterscheidet deshalb drei Schritte: Aktivierung richtet das Plugin ein, Deaktivierung hält den Betrieb an, Deinstallation räumt auf. Ein Hook ist dabei ein festgelegter Zeitpunkt, an dem WordPress eine registrierte Funktion aufruft. Wichtig für Updates: Der Aktivierungs-Hook wird dabei nicht erneut ausgeführt.
Das Beispiel LW Search Log speichert Suchbegriffe in einer eigenen Datenbanktabelle. Eine Versionsnummer beschreibt den Aufbau dieser Tabelle. Bei einem Update passt eine Migration die gespeicherten Daten an den neuen Code an. Eine geplante Aufgabe entfernt alte Einträge; ein eigenes Zugriffsrecht erlaubt den Aufruf der Auswertung. Die Datei uninstall.php löscht die Protokolldaten nur nach ausdrücklicher Zustimmung. Die folgenden Erläuterungen beziehen sich auf WordPress 7.1.2; die Grenzen der überarbeiteten Codefassung stehen am Ende.
Drei Phasen, drei Einstiegspunkte
Die Aktivierung bereitet beispielsweise Tabellen und Standardoptionen vor. Die Deaktivierung entfernt vorübergehende Daten wie Zwischenspeicher und stoppt geplante Aufgaben. Dauerhafte Inhalte bleiben im Beispiel erhalten. Erst die Deinstallation bietet das endgültige Aufräumen an. Diese Trennung ist eine Empfehlung für das Plugin, keine technische Löschsperre von WordPress.
register_activation_hook() und register_deactivation_hook() melden die Funktionen für diese beiden Schritte an. Das erste Argument bezeichnet die Hauptdatei mit dem Plugin-Header; dort passt __FILE__. Beim Deinstallieren lädt WordPress entweder uninstall.php aus dem Plugin-Ordner oder führt den mit register_uninstall_hook() registrierten Aufruf aus. Das geschieht beim regulären Löschen im Backend. WP-CLI kann die Deinstallation auch ausführen und die Dateien behalten. Eine bloße Deaktivierung startet keine Deinstallation.
| Phase | Einstiegspunkt | Beispiel-Plugin: legt an oder entfernt |
|---|---|---|
| Aktivierung | register_activation_hook() | legt Tabelle, Schemaversion, Capability und Cron-Ereignis an |
| Update | kein Aktivierungs-Hook; Versionsprüfung auf plugins_loaded | dieselbe Einrichtung, dazu Datenmigration |
| Deaktivierung | register_deactivation_hook() | entfernt Cron-Ereignis und Transient, behält alles andere |
| Deinstallation | uninstall.php | entfernt immer Capability, Cron und Cache; Tabelle, Optionen und Nutzer-Meta nur nach Opt-in |
Die Aktivierung ist kein Update-Hook
Seit WordPress 3.1 feuert der Aktivierungs-Hook bei Updates nicht mehr. Andrew Nacin hat die Änderung im Oktober 2010 auf Make WordPress Core begründet: Vorher lief der Hook bei Einzel-Updates, bei Sammel-Updates dagegen nicht, und manuelle Updates per FTP oder SVN lösten ihn nie aus. Die Empfehlung aus demselben Beitrag gilt unverändert: eine Version in der Datenbank speichern, die gespeicherte Version mit der Version im Code vergleichen und bei Abweichung die Upgrade-Routine starten.
Der aktuelle Quelltext bestätigt das. Vor einem Update deaktiviert Plugin_Upgrader::deactivate_plugin_before_upgrade() ein aktives Plugin mit deactivate_plugins( $plugin, true ); das zweite Argument bedeutet „still“, es läuft also kein Deaktivierungs-Hook. Bei Hintergrund-Updates per Cron wird das Plugin gar nicht erst deaktiviert. Danach aktiviert wp-admin/update.php das Plugin mit activate_plugin( $plugin, '', ! empty( $_GET['networkwide'] ), true ) wieder, ebenfalls still. Der Docblock von activate_plugin() hält fest, dass eine stille Aktivierung die Aktivierungs-Hooks überspringt.
upgrader_process_complete läuft noch in der Anfrage, die das Update ausführt. Falls die alte Pluginfassung bereits geladen wurde, kann dort weiterhin deren Code laufen. Der Hook allein ist deshalb kein verlässlicher Einstieg für die neue Migration. Die Prüfung auf plugins_loaded vergleicht die gespeicherte Version mit der geladenen Codefassung. Bei einer älteren Datenbankversion startet die Einrichtung erneut.
Eigene Tabelle mit dbDelta und versioniertem Schema
Ob sich eine eigene Tabelle überhaupt lohnt, behandelt der Beitrag zu den Leistungsgrenzen von wp_postmeta; dieses Beispiel verwendet eine eigene Tabelle. dbDelta() steckt in wp-admin/includes/upgrade.php, die ausdrücklich eingebunden werden muss. Die Funktion vergleicht eine CREATE TABLE-Anweisung mit der vorhandenen Tabelle und ergänzt, was fehlt. Die Funktion zerlegt das SQL als Text und erwartet deshalb ein bestimmtes Format. Das Handbuch verlangt:
- jedes Feld in einer eigenen Zeile;
- zwei Leerzeichen zwischen
PRIMARY KEYund der Schlüsseldefinition; KEYstatt des SynonymsINDEX, und mindestens einenKEY;- keine Apostrophe oder Backticks um Feldnamen;
- Feldtypen klein, SQL-Schlüsselwörter wie
CREATE TABLEgroß; - eine Länge bei jedem Feldtyp, der eine annimmt, etwa
int(11).
dbDelta() ersetzt keine vollständige Migration. Die Funktion entfernt keine alten Spalten; ein neuer Spaltenname führt zunächst zu einer zusätzlichen Spalte. Auch eine Rückmeldung über eine geplante Änderung beweist noch keinen erfolgreichen Datenbankzugriff. Das Beispiel prüft deshalb anschließend die benötigten Spaltennamen. Datentypen und Indizes werden damit noch nicht vollständig geprüft. Für Umbenennungen, Löschungen und Datenumwandlungen sind eigene Schritte nötig. $wpdb->get_charset_collate() liefert die Einstellungen für Zeichensatz und Sortierung.
Die folgende Hauptdatei protokolliert Suchanfragen auf der Website. Unter „Werkzeuge“ erscheint eine Auswertung für Konten mit dem Zugriffsrecht lwsl_view_log. Ein täglich geplanter WP-Cron-Aufruf entfernt Einträge, die zum Ausführungszeitpunkt älter als 90 Tage sind. WP-Cron ist vom Auslösen fälliger Aufgaben abhängig: Ohne passenden Aufruf bleiben Einträge länger liegen. Der Code garantiert daher keine Löschung genau nach 90 Tagen.
/**
* 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() lässt sich nacheinander erneut aufrufen, ohne absichtlich zusätzliche Tabellen oder Cron-Ereignisse anzulegen. Das bedeutet „idempotent“; gleichzeitige Aufrufe sind damit noch nicht abgesichert. Eine benannte Datenbanksperre begrenzt die Einrichtung auf einen Aufruf je Website und Datenbankverbindung. Fehlen Spalten, scheitert die Datenmigration oder lässt sich Cron nicht planen, wird Version 2 nicht eingetragen. Die nächste passende Anfrage versucht die Einrichtung erneut. Bis zur erwarteten Version bleiben Protokollierung und Auswertung gesperrt. Version 2 ergänzt result_count und wandelt bei einem Wechsel von Version 1 vorhandene Begriffe in Kleinbuchstaben um.
lwsl_db_version wird häufig gelesen. Autoload lädt diese kleine Option zusammen mit anderen häufig benötigten Optionen. Ohne Autoload ist eine zusätzliche Datenbankabfrage möglich, aber bei vorhandenem Objekt-Cache nicht bei jedem Seitenaufruf zwingend. add_cap() übergibt die Rollenoption an update_option(); ein unveränderter Wert wird nicht erneut gespeichert. Der Platzhalter %i in $wpdb->prepare() behandelt Tabellennamen als Datenbankbezeichner und wird seit WordPress 6.2 unterstützt.
Deaktivierung: anhalten, nicht löschen
Bei der Fehlersuche ist eine Deaktivierung oft vorübergehend. Das Beispiel entfernt deshalb das geplante Cron-Ereignis und die zwischengespeicherte Rangliste. Ein solcher zeitlich begrenzter Zwischenspeicher heißt in WordPress Transient. Tabelle, Schemaversion, Zugriffsrecht und der Zeitpunkt des letzten Besuchs der Auswertung bleiben erhalten.
wp_clear_scheduled_hook() entfernt Ereignisse eines Hooks mit genau den angegebenen Argumenten. wp_unschedule_hook() entfernt dagegen alle Ereignisse dieses Hooks, auch mit unterschiedlichen Argumenten. Das Beispiel plant ohne Argumente; bei der Deaktivierung reicht deshalb die erste Funktion. Die Deinstallation verwendet die zweite Funktion, um auch abweichende Einträge dieses Plugin-Hooks zu entfernen.
Bei einer Netzwerkdeaktivierung erhält die Funktion den Wert true. Die Schleife verarbeitet dann nur die Websites des aktuellen Netzwerks. Das Zugriffsrecht bleibt erhalten, weil eine spätere Aktivierung damit weiterarbeiten kann. Die Auswertung selbst ist bei deaktiviertem Plugin nicht mehr vorhanden.
Deinstallation: uninstall.php, register_uninstall_hook und das Opt-in
Beim regulären Löschen startet delete_plugins() zunächst uninstall_plugin(). Existiert uninstall.php, hat diese Datei Vorrang vor einem registrierten Callback. WordPress setzt WP_UNINSTALL_PLUGIN und lädt die Datei, ohne dafür die Hauptdatei nachzuladen. Deshalb enthält die Deinstallationsdatei die benötigten Namen ausdrücklich. Ohne uninstall.php lädt WordPress die Hauptdatei und ruft den gespeicherten Callback auf; dabei läuft auch Code außerhalb von Funktionen.
Die Prüfung auf WP_UNINSTALL_PLUGIN verhindert den vorgesehenen Ablauf bei einem direkten Dateiabruf. Ein mit register_uninstall_hook() registrierter Callback wird dauerhaft in einer Option gespeichert. Eine Closure, also eine anonyme Funktion, lässt sich dafür nicht serialisieren und verursacht einen Fehler. Eine Methode eines konkreten Objekts wird ebenfalls abgelehnt. Geeignet sind ein Funktionsname oder eine statische Klassenmethode. Die Registrierung gehört in die Aktivierung. Die folgenden Aufrufe zeigen Alternativen und Fehlerfälle; der Block ist kein gemeinsam auszuführender Einrichtungscode.
// 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' ) );Das Entfernen der Plugin-Dateien muss nicht das Ende der gespeicherten Inhalte bedeuten: Eine Neuinstallation kann dieselben Daten weiterverwenden. Deshalb entfernt die Routine Cron, Zwischenspeicher und Rollenrechte unabhängig vom Löschschalter. Die Protokolltabelle und die zugehörigen Einstellungen werden nur beim Wert 1 gelöscht. In Multisite gilt dieser Schalter je Netzwerk. Scheitert das Löschen einer Tabelle, bricht die Routine ab, statt anschließend den zugehörigen Versionsnachweis zu entfernen.
/**
* 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.Der Zeitpunkt des letzten Besuchs erhält nun einen Schlüssel mit Website-Präfix, etwa wp_2_lwsl_last_viewed. Dadurch verändert der Besuch einer Auswertung nicht den Zähler einer anderen Website. delete_metadata() mit $delete_all = true entfernt genau diesen Schlüssel für alle Konten. Alte Werte unter dem früheren gemeinsamen Schlüssel bleiben bewusst erhalten, weil eine eindeutige Zuordnung fehlt. delete_transient() berücksichtigt auch einen externen Objekt-Cache; direktes Löschen in wp_options würde diesen Cache nicht leeren.
Die folgenden Befehle löschen Daten und gehören ausschließlich in eine entbehrliche Testinstallation. Wie der Schalter gesetzt wird, ist Sache der Einstellungsseite; die Settings API behandelt ein eigener Beitrag dieser Serie. Für einen Test genügt WP-CLI. wp plugin uninstall mit --skip-delete führt nur die Deinstallationsroutine aus und lässt die Dateien liegen, wp plugin delete dagegen entfernt die Dateien, ohne zu deaktivieren oder zu deinstallieren. Diesen Unterschied vertieft der Beitrag zu den WP-CLI-Löschbefehlen und den betroffenen Tabellen.
# 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-logMultisite: einmal je Netzwerk, Daten je Website
Tabellen, Optionen, Cron-Ereignisse und Rollen werden im Beispiel je Website verwaltet. Die Nutzermetadaten liegen dagegen in einer gemeinsam genutzten Tabelle. Das Website-Präfix im Metaschlüssel trennt die gespeicherten Besuchszeiten. Der Löschschalter wird für das jeweilige Netzwerk mit get_network_option() gelesen; eine Einzelinstallation verwendet get_option(). Dadurch entscheidet nicht versehentlich die Zustimmung eines Netzwerks über Daten eines anderen.
Die Netzwerkaktivierung ruft den Aktivierungs-Hook einmal auf, nicht einmal pro Website. Die eigene Schleife liest deshalb jeweils bis zu 100 Website-IDs des aktuellen Netzwerks. switch_to_blog() wechselt den Website-Kontext, lädt aber keine andere Pluginfassung. finally stellt den vorherigen Kontext auch nach einer Ausnahme wieder her. Die Deinstallation berücksichtigt dagegen alle Netzwerke, weil die Plugin-Dateien gemeinsam genutzt werden.
Sehr große Netzwerke passen möglicherweise nicht in eine einzelne Aktivierungsanfrage. wp_is_large_network() prüft ohne weiteres Argument die Zahl der Websites: Standardmäßig gilt mehr als 10.000 als groß. Die Nutzerzahl wird nur mit dem dafür vorgesehenen Argument geprüft; Filter können die Entscheidung ändern. Im großen Netzwerk verschiebt das Beispiel die Einrichtung bis zur ersten Anfrage jeder Website mit aktivem Plugin. Das gilt auch für später angelegte Websites. Eine sofortige Einrichtung neuer Websites wäre zusätzlich über wp_initialize_site nach der Core-Initialisierung möglich.
Der Filter wpmu_drop_tables ergänzt die Plugin-Tabelle beim Löschen einer Website. Das funktioniert nur, wenn der Plugin-Code dabei geladen ist. Bei deaktiviertem Plugin kann die Tabelle zurückbleiben. Auch die spätere Deinstallation findet eine bereits gelöschte Website nicht mehr über get_sites(). Solche Reste brauchen eine gesonderte, überprüfte Bereinigung. Eine vollständige Deinstallation über viele bestehende Websites kann außerdem Zeitlimits erreichen; das Beispiel besitzt keine fortsetzbare Auftragswarteschlange.
Grenzen und offene Punkte
Der historische Prüfbericht dokumentiert die ursprüngliche Fassung unter WordPress 7.1.2, PHP 8.4 und MariaDB 11.8 auf einer Einzelinstallation und einem Netzwerk mit Unterverzeichnissen. Dazu gehören Aktivierung, Schemawechsel, neue und gelöschte Websites sowie Deinstallation mit und ohne Zustimmung. Plugin Check meldete damals nur die fehlende readme.txt. Für die hier überarbeitete Fassung wurden PHP-Syntax und zwölf isolierte Testfälle einschließlich Fehlerfällen mit nachgebildeten WordPress-Funktionen geprüft. Eine erneute vollständige WordPress-, Mehrnetzwerk- oder echte Parallelprüfung dieser Fassung ist damit nicht belegt.
- Die Sperre
GET_LOCK()gilt auf dem angesprochenen Datenbankserver und bleibt an die Verbindung gebunden. Ein Verbindungsabbruch oder eine andere Datenbank hinter einem Proxy kann diesen Schutz aufheben. Die Sperre ist keine Transaktion und macht bereits ausgeführte Änderungen nicht rückgängig. Diese Umgebung muss vor einem produktiven Einsatz geprüft werden. - Das Handbuch verlangt Längenangaben auch bei Ganzzahltypen wie
int(11). Unter MariaDB 11.8 meldetedbDelta()mitint(11)bei wiederholten Aufrufen keine Änderung; ohne Längenangabe meldete es bei jedem Aufruf „Changed type“, weil MariaDBint(10)ausweist. Unter MySQL ab 8.0.17 übergeht der Core reine Breitenunterschiede seit WordPress 6.1 (laut Quelltext, nicht gemessen). - Capabilities, die einzelnen Nutzern direkt zugewiesen wurden, liegen in der Nutzer-Meta und bleiben von der Schleife über die Rollen unberührt.
- Die überarbeitete Deinstallation liest die Zustimmung für jedes Netzwerk getrennt und löscht Besuchszeiten nur unter dem jeweiligen Website-Präfix. Alte gemeinsame Metaschlüssel und direkt an einzelne Konten vergebene Rechte brauchen eine getrennte Bereinigung. Ein Fehler kann bereits erledigte Schritte nicht rückgängig machen.
- Das bloße Entfernen von Dateien über FTP, Git oder ein Deployment startet
uninstall.phpnicht. Ein solcher Ablauf braucht bei gewünschter Datenbereinigung einen eigenen, vorab geprüften Schritt.
Fragen und Antworten
Läuft uninstall.php auch, wenn ein Plugin per FTP oder mit wp plugin delete entfernt wird?
Nein. FTP und wp plugin delete entfernen die Dateien, ohne die Deinstallationsroutine auszuführen. wp plugin uninstall startet dagegen die Routine; mit --skip-delete bleiben die Dateien erhalten. Ein Git- oder Deployment-Ablauf räumt nur dann zusätzlich Daten auf, wenn ein entsprechender Schritt ausdrücklich eingerichtet ist.
Was passiert, wenn ein Plugin sowohl uninstall.php als auch register_uninstall_hook() verwendet?
uninstall.php hat Vorrang. WordPress entfernt in diesem Ablauf den registrierten Callback aus der Option uninstall_plugins und lädt die Datei. Der andere Callback wird nicht zusätzlich ausgeführt.
Warum steht die Versionsprüfung auf plugins_loaded und nicht auf admin_init?
admin_init läuft bei Verwaltungsseiten sowie bei admin-ajax.php und admin-post.php, aber nicht bei einem gewöhnlichen Frontend-Aufruf. Nach einem Dateiaustausch könnte das Frontend deshalb noch auf die alte Tabellenstruktur treffen. plugins_loaded prüft die Version früher in Anfragen, die das Plugin laden. Im Beispiel bleibt die Nutzung der Tabelle bis zum erwarteten Versionsstand zusätzlich gesperrt.
Quellen
Ursprünglicher Quellenstand: 29.09.2026. Die beschriebenen Korrekturen wurden am 25.10.2026 anhand der WordPress-Referenz und der MariaDB-Dokumentation gegengeprüft.
- Plugin Handbook: Activation / Deactivation Hooks
- Plugin Handbook: Uninstall Methods
- Plugin Handbook: Creating Tables with Plugins
- Make WordPress Core: Plugin activation hooks no longer fire for updates (27.10.2010)
- Funktionsreferenz: register_deactivation_hook()
- Funktionsreferenz: register_uninstall_hook()
- Funktionsreferenz: dbDelta()
- Hook-Referenz: upgrader_process_complete
- Funktionsreferenz: wp_clear_scheduled_hook()
- Funktionsreferenz: wp_unschedule_hook()
- Funktionsreferenz: get_sites()
- Funktionsreferenz: switch_to_blog()
- Quelltext WordPress 7.1.2: wp-admin/includes/plugin.php
- Quelltext WordPress 7.1.2: wp-admin/update.php
- Quelltext WordPress 7.1.2: class-plugin-upgrader.php
- Quelltext WordPress 7.1.2: wp-includes/plugin.php
- Quelltext WordPress 7.1.2: wp-includes/option.php
- Quelltext WordPress 7.1.2: wp-includes/meta.php
- Quelltext WordPress 7.1.2: class-wp-roles.php
- Quelltext WordPress 7.1.2: wp-includes/cron.php
- Quelltext WordPress 7.1.2: wp-includes/ms-site.php
- Quelltext WordPress 7.1.2: ms-default-filters.php
- Quelltext WordPress 7.1.2: ms-functions.php
- Quelltext WordPress 7.1.2: capabilities.php
- Quelltext WordPress 7.1.2: class-wpdb.php
- WP-CLI: wp plugin uninstall
- WP-CLI: wp plugin delete
- WP-CLI: wp site option update
- admin_init
- wp_is_large_network()
- WP_Site
- MariaDB: GET_LOCK