Plugin-Lebenszyklus: Aktivierung, Deaktivierung, Deinstallation

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
Viele Plugins hinterlassen Spuren, lange nachdem sie ihre Arbeit eingestellt haben: eine Tabelle, die niemand mehr liest, Cron-Ereignisse, für die kein Callback mehr vorhanden ist, oder eine Capability an der Administratorrolle, die keinen Zugriff mehr regelt. WordPress kennt drei getrennte Einstiegspunkte für Aktivierung, Deaktivierung und Deinstallation. Dabei gibt es leicht zu übersehende Besonderheiten: Der Aktivierungs-Hook läuft bei Updates nicht, eine Netzwerkaktivierung auf Multisite löst ihn genau einmal statt einmal je Website aus, und eine als Closure registrierte Deinstallationsroutine endet in einem fatalen Fehler.
Dieser Beitrag begleitet ein kleines, aber vollständiges Plugin durch alle drei Phasen. Es legt mit dbDelta() eine eigene Tabelle an, speichert eine Schemaversion und migriert bei Updates, plant ein tägliches Cron-Ereignis, gibt Administratoren eine eigene Capability und räumt all das über eine uninstall.php wieder ab, die Daten nur nach ausdrücklicher Zustimmung löscht. Jede Aussage zum Verhalten des Core ist am Handbuch, an der Funktionsreferenz und am Quelltext von WordPress 7.1.2 geprüft, der aktuellen Version am 29.09.2026.
Drei Phasen, drei Einstiegspunkte
Das Plugin-Handbuch trennt die Phasen klar: Die Aktivierung ist der Ort für Rewrite-Regeln, eigene Tabellen und Standardoptionen, die Deaktivierung entfernt Vorläufiges wie Caches, und erst die Deinstallation darf dauerhafte Daten wie Optionen und Tabellen löschen.
Technisch sind register_activation_hook() und register_deactivation_hook() dünne Hüllen. Beide wenden plugin_basename() auf die übergebene Datei an und hängen den Callback an eine dynamische Action, activate_{$plugin} beziehungsweise deactivate_{$plugin}. Das erste Argument muss deshalb die Hauptdatei mit dem Plugin-Header sein, üblicherweise __FILE__ in genau dieser Datei. Die Deinstallation funktioniert anders: Entweder bindet WordPress eine uninstall.php aus dem Plugin-Ordner ein, oder es ruft einen mit register_uninstall_hook() hinterlegten Callback auf. Beide Varianten laufen nur beim Löschen, nicht beim Deaktivieren.
| Phase | Einstiegspunkt | Beispiel-Plugin: legt an oder entfernt |
|---|---|---|
| Aktivierung | register_activation_hook() |
legt Tabelle, Schemaversion, Capability und Cron-Ereignis an |
| Update | kein 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, sie 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.
Der Hook upgrader_process_complete taugt für Migrationen kaum. Er feuert in der Anfrage, die das Update ausgeführt hat, und ein Hinweis in der Hook-Referenz weist darauf hin, dass in diesem Moment noch der alte Code des Plugins läuft. Eine Migration für Version 2 käme so erst ein Update später zum Zug. Die Versionsprüfung auf plugins_loaded, wie sie das Handbuch im Kapitel über eigene Tabellen zeigt, läuft dagegen bei jeder Anfrage mit dem neuen Code, gleich auf welchem Weg das Update kam.
Eigene Tabelle mit dbDelta und versioniertem Schema
Ob sich eine eigene Tabelle überhaupt lohnt, behandelt der Beitrag zu den Leistungsgrenzen von wp_postmeta; hier ist sie gesetzt. 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. Weil sie das SQL als Text zerlegt, ist sie beim Format pingelig. 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).
Die Funktionsreferenz ergänzt Grenzen: dbDelta() löscht keine Spalten, ein umbenanntes Feld landet als zusätzliche Spalte in der Tabelle, und die zurückgegebenen Meldungen belegen nicht, dass eine Änderung tatsächlich ausgeführt wurde. Umbenennen, Entfernen und Umwandeln von Daten gehören deshalb in eigene Migrationsschritte. Den Zeichensatz liefert $wpdb->get_charset_collate().
Es folgt die vollständige Hauptdatei des Beispiel-Plugins. Es protokolliert Suchanfragen im Frontend in einer Tabelle, löscht einmal täglich Zeilen, die älter als 90 Tage sind, und zeigt unter „Werkzeuge“ die häufigsten Begriffe für alle, die die Capability lwsl_view_log besitzen.
<?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 );
Kern des Plugins ist lwsl_setup_site(). Die Funktion ist idempotent: dbDelta() ändert nichts, wenn die Tabelle schon passt, die Capability wird nur geschrieben, wenn sie fehlt, und das Cron-Ereignis wird nur geplant, wenn wp_next_scheduled() keines findet. Damit lässt sie sich gefahrlos aus dem Aktivierungs-Hook, aus der Versionsprüfung und für noch nicht eingerichtete Websites im Netzwerk aufrufen. Schemaversion 2 zeigt beide Arten von Migration. Die neue Spalte result_count ergänzt dbDelta() von selbst, das Umwandeln vorhandener Begriffe in Kleinbuchstaben ist dagegen eine Datenmigration mit eigenem Schritt, der nur bei gespeicherter Version 1 läuft.
Zwei Details gehen leicht unter. Die Option lwsl_db_version wird bei jeder Anfrage gelesen und deshalb mit Autoload gespeichert; sonst kostet jeder Seitenaufruf eine zusätzliche Abfrage. Bei jedem Aufruf übergibt add_cap() die gesamte Rollenoption an update_option(). Diese Funktion schreibt nur dann in die Datenbank, wenn die Capability neu ist. Das Beispiel vergibt sie trotzdem nur bei der Einrichtung und nicht auf init. Tabellennamen werden mit dem Platzhalter %i übergeben, den $wpdb->prepare() seit WordPress 6.2 für Bezeichner unterstützt.
Deaktivierung: anhalten, nicht löschen
Eine Deaktivierung ist oft nur vorübergehend, etwa bei der Fehlersuche. Das Beispiel entfernt deshalb nur, was ohne Plugin weiterlaufen oder bei der Reaktivierung veraltet sein würde: das Cron-Ereignis und die zwischengespeicherte Rangliste im Transient. Tabelle, Schemaversion, Capability und Nutzer-Meta bleiben erhalten.
Für Cron gibt es zwei Funktionen. wp_clear_scheduled_hook() entfernt alle Ereignisse eines Hooks mit den angegebenen Argumenten, und die Referenz betont, dass diese exakt übereinstimmen müssen. Plant ein Plugin denselben Hook mit unterschiedlichen Argumenten, hilft nur wp_unschedule_hook(): Die Funktion gibt es seit 4.9, sie entfernt alle Ereignisse eines Hooks unabhängig von den Argumenten, und ihr Docblock nennt das Aufräumen der Cron-Warteschlange beim Deaktivieren ausdrücklich als Einsatzzweck. Das Beispiel plant ohne Argumente; die Deinstallationsroutine greift trotzdem sicherheitshalber zur zweiten Funktion.
Der Deaktivierungs-Callback erhält einen booleschen Wert, der auf Multisite bei einer Netzwerkdeaktivierung wahr ist. lwsl_deactivate() räumt in diesem Fall alle Websites auf. Die Capability bleibt beim Deaktivieren bewusst stehen: Ohne Plugin prüft sie kein Code, und Entfernen plus erneutes Hinzufügen hieße zwei unnötige Schreibvorgänge in die Rollenoption je Website.
Deinstallation: uninstall.php, register_uninstall_hook und das Opt-in
Beim Löschen ruft delete_plugins() für jedes Plugin uninstall_plugin() auf, bevor die Dateien verschwinden. Der Quelltext klärt auch den Vorrang zwischen beiden Varianten: Existiert eine uninstall.php, entfernt WordPress einen eventuell registrierten Callback aus der Option uninstall_plugins, definiert die Konstante WP_UNINSTALL_PLUGIN mit dem Plugin-Basename und bindet nur diese Datei ein. Die Hauptdatei des Plugins wird dabei nicht geladen. Deshalb schreibt die Datei weiter unten jeden Namen aus, statt Konstanten wie LWSL_CAP zu verwenden. Erst wenn keine uninstall.php existiert, bindet WordPress die Hauptdatei ein und führt den gespeicherten Callback auf uninstall_{$file} aus; dann läuft auch jede Codezeile auf Dateiebene mit.
Die Prüfung auf WP_UNINSTALL_PLUGIN am Anfang der Datei schützt vor direktem Aufruf, wie es das Handbuch verlangt. Für register_uninstall_hook() gelten strengere Regeln als für die beiden anderen Registrierungsfunktionen, weil der Callback in einer Option landet. Eine Closure lässt sich nicht serialisieren und endet in einem fatalen Fehler; eine Objektmethode wird mit einem _doing_it_wrong()-Hinweis abgelehnt. Erlaubt sind nur Funktionsnamen und statische Methoden. Jeder Aufruf liest die Option und schreibt sie, sobald der Eintrag fehlt oder abweicht; die Registrierung gehört deshalb in die Aktivierung und nicht in jede Anfrage.
// 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' ) );
Was bedeutet „Daten beim Deinstallieren löschen“ als Opt-in? Wer ein Plugin löscht, verabschiedet sich nicht zwingend von dessen Daten; auch eine Neuinstallation, die beschädigte Dateien ausschließen soll, beginnt mit dem Löschen. Das Beispiel teilt das Aufräumen deshalb in zwei Gruppen. Technische Reste gehen immer: Cron-Ereignisse, Transients und die Capability sind ohne Plugin nutzlos und entstehen bei der Aktivierung ohnehin neu. Inhalte und Konfiguration gehen nur nach ausdrücklichem Ja: die Protokolltabelle, die Schemaversion, die Nutzer-Meta und der Schalter selbst. Ohne Opt-in findet eine Neuinstallation das alte Protokoll samt Schemaversion vor.
<?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' );
}
Die Nutzer-Meta entfernt delete_metadata() mit dem fünften Argument $delete_all auf true, das den Schlüssel für alle Nutzer auf einmal löscht. delete_user_meta() arbeitet dagegen immer nur mit einer Nutzer-ID. delete_transient() berücksichtigt zudem einen externen Objekt-Cache und löscht den Eintrag in diesem Fall dort statt in der Optionstabelle. Eine LIKE-Abfrage auf _transient_% in wp_options erreicht einen solchen Cache nicht.
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 Tabellen, die sie anfassen.
# 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: einmal je Netzwerk, Daten je Website
Auf Multisite existieren die meisten Daten des Beispiels je Website: Die Tabelle trägt das Präfix der Website, Optionen und Transients liegen je Website, Cron-Ereignisse in der Option cron jeder Website und Rollen in einer Option aus Website-Präfix plus user_roles. Nutzer-Meta dagegen liegt in einer einzigen Tabelle für das ganze Netzwerk, und der Opt-in-Schalter wird über get_site_option() als Netzwerkoption gelesen, die auf Einzelinstallationen auf eine normale Option zurückfällt.
Eine Netzwerkaktivierung löst den Aktivierungs-Hook genau einmal aus, im Kontext der aktuellen Website und mit $network_wide auf true. activate_plugin() setzt diesen Wert auch dann, wenn der Plugin-Header Network: true enthält. Um alle anderen Websites muss sich das Plugin selbst kümmern. lwsl_for_each_site() erledigt das mit get_sites() und switch_to_blog(). Dabei lauern zwei Fallen: get_sites() liefert ohne weitere Angabe höchstens 100 Websites, weshalb die Schleife ausdrücklich blättert; und switch_to_blog() wechselt nur den Datenbankkontext, nicht den geladenen Code. Hier spielt das keine Rolle, weil die Schleife nur Funktionen dieses Plugins aufruft.
In großen Netzwerken kann eine Schleife über alle Websites während der Aktivierung an Zeitlimits stoßen. WordPress stuft ein Netzwerk ab mehr als 10.000 Websites oder Nutzern als groß ein, und wp_is_large_network() lässt sich filtern. In diesem Fall überspringt das Beispiel die Schleife und verlässt sich auf die Versionsprüfung: lwsl_maybe_upgrade() läuft bei jeder Anfrage jeder Website, auf der das Plugin aktiv ist, und richtet eine Website bei ihrer ersten Anfrage ein. Das deckt auch Websites ab, die nach der Aktivierung entstehen. Wer neue Websites sofort einrichten will, hängt sich an wp_initialize_site; der Core führt dort mit Priorität 10 seine eigene Initialisierung aus, ein Plugin-Callback gehört also auf eine spätere Priorität.
Das Löschen einer einzelnen Website regelt der Filter wpmu_drop_tables, der die Tabellen auflistet, die WordPress mit der Website entfernt. Das Beispiel trägt seine Tabelle dort unabhängig vom Opt-in ein, denn die ganze Website verschwindet ohnehin. Das Löschen des Plugins selbst ist auf Multisite Super-Admins vorbehalten, wie die Capability-Zuordnung von delete_plugins zeigt, und uninstall.php läuft dann einmal im Netzwerk-Admin. Die Schleife über alle Websites in der Deinstallationsroutine ist deshalb Pflicht; das Handbuch warnt ausdrücklich, dass sie in großen Netzwerken viele Ressourcen kosten kann.
Grenzen und offene Punkte
Der Beispielcode lief unter WordPress 7.1.2 (PHP 8.4, MariaDB 11.8) auf einer Einzelinstallation und in einem Netzwerk mit Unterverzeichnissen: Aktivierung, Netzwerkaktivierung, nachträglich angelegte Site, Löschen einer Site, Update von Schemaversion 1 auf 2, Deaktivierung sowie Deinstallation mit und ohne Opt-in. Plugin Check 2.1.0 meldet für die beiden PHP-Dateien keinen Befund.
- Parallele Anfragen direkt nach einem Update können
lwsl_setup_site()doppelt ausführen. Hier ist das unschädlich, weil jeder Schritt idempotent ist; andere Migrationen brauchen eine Sperre. - 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.
- In Installationen mit mehreren Netzwerken liefert
get_sites()ohnenetwork_iddie Websites aller Netzwerke, während der Opt-in-Schalter je Netzwerk gespeichert ist. Das richtige Verhalten für diesen Fall bleibt offen. - Wer Plugins per FTP, Git oder Deployment-Skript entfernt, umgeht
uninstall.phpvollständig. Aufräumen kann dann kein Plugin.
Fragen und Antworten
Läuft uninstall.php auch, wenn ein Plugin per FTP oder mit wp plugin delete entfernt wird?
Nein. WordPress ruft die Deinstallationsroutine nur aus delete_plugins() heraus auf, also beim Löschen im Backend. WP-CLI bietet dafür wp plugin uninstall; wp plugin delete entfernt laut Dokumentation nur die Dateien, ohne zu deaktivieren oder die Deinstallationsroutine auszuführen. Bei FTP, Git oder Deployment-Skripten bleibt alles in der Datenbank liegen.
Was passiert, wenn ein Plugin sowohl uninstall.php als auch register_uninstall_hook() verwendet?
Die Datei gewinnt. uninstall_plugin() prüft zuerst, ob uninstall.php existiert, entfernt dann den registrierten Callback aus der Option uninstall_plugins und bindet nur die Datei ein. Der Callback wird in diesem Fall nie aufgerufen.
Warum steht die Versionsprüfung auf plugins_loaded und nicht auf admin_init?
Updates kommen nicht nur über das Backend, sondern auch über WP-CLI, automatische Hintergrund-Updates oder das Hochladen neuer Dateien. Auf admin_init liefe die Migration erst, wenn jemand das Backend öffnet; bis dahin arbeitet der neue Code im Frontend mit dem alten Schema. plugins_loaded läuft bei jeder Anfrage, das Handbuch zeigt die Prüfung genau dort. Die Option mit der Schemaversion sollte deshalb mit Autoload gespeichert sein.
Quellen
Alle Quellen abgerufen am 29.09.2026.
- 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