The plugin model
Activation, Deactivation and Uninstall
What WordPress actually runs when a plugin is activated, deactivated or deleted, and why the answer is not the same on all three paths.
You register a custom post type, activate the plugin, and every URL for that type returns 404 until somebody visits the Permalinks screen. Or you copy a plugin onto a server and the settings page reports that the table holding the notes was never created, because the code that creates it lives in a callback that never ran.
Both come from the same gap: the code that installs a plugin is not the code that serves a page view. WordPress gives you three entry points, they fire at different moments, and only one of them ever runs.
Activation is a one-off request, not part of a page view
Activation happens when somebody clicks Activate on the Plugins screen. In that same
request WordPress loads the plugin's main file and fires a hook named after the plugin's
basename. register_activation_hook() is the wrapper: the file you pass is
reduced to a basename with plugin_basename(), and your callback is attached to
activate_{basename}.
<?php
/**
* Plugin Name: WPSifero Notes
* Version: 0.1.0
* Requires at least: 7.0
* Requires PHP: 8.3
*/
defined( 'ABSPATH' ) || exit;
define( 'WPSIFERO_NOTES_VERSION', '0.1.0' );
define( 'WPSIFERO_NOTES_FILE', __FILE__ );
register_activation_hook( __FILE__, 'wpsifero_notes_activate' );
function wpsifero_notes_activate( bool $network_wide ): void {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$table = $wpdb->prefix . 'wpsifero_notes';
$collate = $wpdb->get_charset_collate();
dbDelta(
"CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
note text NOT NULL,
created_at datetime NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id)
) {$collate};"
);
add_option( 'wpsifero_notes_schema', WPSIFERO_NOTES_VERSION, '', false );
flush_rewrite_rules();
}
Three details in that function matter more than the SQL. The callback receives
$network_wide, true when the plugin was activated for the whole network, and a
plugin that stores per site data has to decide what that means rather than ignore it. The
dbDelta() call needs wp-admin/includes/upgrade.php, which happens
to be loaded during an admin request and is not worth depending on; requiring it explicitly
costs one opcache hit. The schema version it stores is what lets a later request notice an
out of date table.
Because this is a real request with WordPress loaded, the database, the filesystem API, the current user and the HTTP API are all available. What is not available is any assumption that activation will run again: copy files over an existing install and nothing in this callback happens.
A custom post type needs its rewrite rules flushed, but only once
The 404 is worth understanding rather than memorising. WordPress stores the compiled
rewrite rules, the regular expressions that map a URL to query variables, in the
rewrite_rules option, and routing reads that option on every request. Rules are
not rebuilt from registered post types and taxonomies each time, because that would be
wasteful. A plugin that registers a post type is therefore registering rules that nothing has
compiled into the stored set yet.
flush_rewrite_rules() forces the rebuild. Called during activation it is
right: the post type was registered a few lines earlier in the same request, so the stored
set includes it. Called on every request it is harmful, because each call rebuilds every rule
and writes the option: a database write and a site-wide cache invalidation per page view,
with a cost that grows with every plugin registering something.
When the flush happens
Core refuses to write rewrite rules before loading is finished, because a partial rule
set is worse than a stale one. Call flush_rewrite_rules() before
wp_loaded has fired and WordPress schedules the real flush for
wp_loaded. During activation that hook has already run, so the flush writes
immediately.
The pattern that survives redeployment is a check rather than a hope. Whatever the install path, a request can compare the schema version stored in the options table with the version the code expects, and do the work when they disagree.
add_action( 'plugins_loaded', 'wpsifero_notes_maybe_upgrade', 5 );
function wpsifero_notes_maybe_upgrade(): void {
if ( get_option( 'wpsifero_notes_schema' ) === WPSIFERO_NOTES_VERSION ) {
return;
}
wpsifero_notes_activate( is_multisite() );
error_log( 'WPSifero Notes: schema upgraded to ' . WPSIFERO_NOTES_VERSION );
}
Two things there are deliberate. The version constant is bumped by hand when the schema changes, so the check costs one option read on a normal request. And the upgrade routine is the same function the activation hook calls, so there is one definition of a correct install. An admin notice is the noisier alternative to a log line.
Deactivation is a pause, so it must not delete data
Deactivating removes the plugin from the active list and fires
deactivate_{basename}, which is what
register_deactivation_hook() attaches to. The files are still there and so is
the data. The site owner may be deactivating to test whether your plugin is causing a
problem, and will reactivate it in an hour expecting their settings back.
Deleting options, tables or posts here is the mistake that generates support threads, and it is hard to recover from because the data is gone before anyone notices. Use the callback for work that is genuinely about stopping: clearing the scheduled events you created, deleting transients that only existed to speed up your own code, resetting a maintenance flag so the next activation starts clean.
register_deactivation_hook( __FILE__, 'wpsifero_notes_deactivate' );
function wpsifero_notes_deactivate(): void {
wp_clear_scheduled_hook( 'wpsifero_notes_digest' );
delete_transient( 'wpsifero_notes_recent' );
}
Leftover cron events have a recognisable symptom: something keeps scheduling the event, the callback is gone, and WordPress fires an action with no listeners. Across a site's plugin set those leftovers are scheduled work that does nothing.
The only data worth touching here is data that is meaningless without the plugin and regenerates on demand. Transients qualify. Options and custom table rows do not.
Uninstall has two paths and only one of them is simple
Deleting a plugin is a different operation from deactivating it, and WordPress looks for
cleanup code in two places, in this order. First, a file called uninstall.php in
the plugin directory. If it exists, WordPress defines the WP_UNINSTALL_PLUGIN
constant, includes that file and runs nothing else from your plugin. Second, if there is no
uninstall.php, the value stored by register_uninstall_hook().
That second path explains the function's odd shape. The callback is stored in the
uninstall_plugins option, keyed by plugin basename. Nothing defines it in the
request that deletes the plugin, so WordPress includes your main file to get it, then fires
uninstall_{basename}. So the callback must be a function or a static method
rather than an object method, and any code at the top of the main file runs at that moment.
Keep that top level to declarations and hook registrations.
<?php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
global $wpdb;
delete_option( 'wpsifero_notes_schema' );
delete_option( 'wpsifero_notes_display_settings' );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wpsifero_notes" );
The guard is defensive rather than necessary, since WordPress defines the constant
before including the file. It refuses a request that fetched uninstall.php
directly through the web server, which would do nothing useful anyway.
Which path to choose turns on what your cleanup has to do.
uninstall.php is easier to reason about: a standalone script that runs with
WordPress loaded but without your plugin's classes, and the path core recommends. A
registered hook suits cleanup that should run through your own code. Cleanup that has to
touch every site on a network belongs in uninstall.php, which has the whole
WordPress API and can enumerate sites; the stored hook runs once with no site context.
Remove your options, tables, scheduled events, transients, custom roles and capabilities. Leave anything you did not create, including rows in shared tables you cannot identify as yours. A plugin that drops a table another plugin also writes to has caused a data loss incident.
One limit worth stating plainly: the uninstall path does not run when a site is restored from a backup, when a plugin is removed by deleting the directory over SFTP, or when a host cleans files up during a migration. The hooks cover what the admin offers.
A plugin deployed by copying files has never been activated
The deployment that causes the most confusion copies the plugin directory into place and activates nothing. The failure mode is a plugin that appears active, renders its settings screen and then fails at the first database call, because the table it expects was never created.
Requiring a manual activation is not reasonable, since site owners copy working installs and hosted platforms provision sites by copying files. Make the plugin self-healing instead: register the schema check and let the first request after deployment do the work.
The same applies to rewrite rules. A plugin whose post type exists in code but not in the stored rules produces 404s on every entry, which looks like a routing bug and is really an installation step that did not happen. Flush when the stored version disagrees with the code rather than on every request, or offer a tool that flushes on demand from the settings screen.
To check the order of these hooks rather than trust it, a probe plugin with one log line per callback settles the question in two minutes. On the install I tested, the activation callback saw the current user loaded, the admin constants defined, and the active plugins option still showing the plugin as inactive: WordPress fires the hook before it writes the option.
Take the plugin from lesson 1 and give it a real install path. Add an activation
callback that creates a table with dbDelta(), stores a schema version option,
and flushes rewrite rules. Add a deactivation callback that clears a scheduled event and
one transient. Add an uninstall.php that removes the options and the table.
Then break it on purpose, three times, recording what you see each time. Activate,
deactivate and reactivate, and confirm the first activation's data survived. Delete the
schema option by hand so the plugin looks uninstalled, then load the front end. Finally
remove uninstall.php, move the cleanup into a registered uninstall hook, and
delete the plugin through the admin.
Finally, answer: which of the three experiments would have left a table behind on a site that only ever updated by copying files, and what single piece of code would have prevented it?
Show the solution and the reasoning
Activation, deactivation and uninstall are three different lifetimes, and the solution keeps them separate: activation creates, deactivation tidies up work in progress, uninstall removes.
<?php
/**
* Plugin Name: WPSifero Notes
* Version: 0.1.0
* Requires at least: 7.0
* Requires PHP: 8.3
*/
defined( 'ABSPATH' ) || exit;
define( 'WPSIFERO_NOTES_VERSION', '0.1.0' );
require_once plugin_dir_path( __FILE__ ) . 'includes/class-plugin.php';
register_activation_hook( __FILE__, 'wpsifero_notes_activate' );
register_deactivation_hook( __FILE__, 'wpsifero_notes_deactivate' );
function wpsifero_notes_activate( bool $network_wide ): void {
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$table = $wpdb->prefix . 'wpsifero_notes';
dbDelta(
"CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
post_id bigint(20) unsigned NOT NULL,
note text NOT NULL,
PRIMARY KEY (id),
KEY post_id (post_id)
) " . $wpdb->get_charset_collate() . ';'
);
update_option( 'wpsifero_notes_schema', WPSIFERO_NOTES_VERSION, false );
if ( ! wp_next_scheduled( 'wpsifero_notes_digest' ) ) {
wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpsifero_notes_digest' );
}
flush_rewrite_rules();
}
function wpsifero_notes_deactivate(): void {
wp_clear_scheduled_hook( 'wpsifero_notes_digest' );
delete_transient( 'wpsifero_notes_recent' );
}
The reactivation experiment shows why deletion does not belong in the deactivation
callback. Everything from the first activation survives a deactivate and reactivate cycle,
because dbDelta() alters an existing table rather than dropping it, and the
schedule is guarded by a check for an existing event. That guard matters: without it every
activation stacks another event with the same hook and argument, and the digest runs
several times a day.
Deleting the schema option while the table still exists is the interesting failure. The version check sees a mismatch, runs the activation routine again and recreates nothing. The migration path and the install path produce the same end state, so a half-installed plugin repairs itself on the next request. Had the routine dropped the table first, this experiment would have destroyed live data.
Removing uninstall.php and registering a hook changes the execution
context rather than the outcome. WordPress includes the main file to find the callback,
then fires uninstall_{basename}. So the file loads and its hook registrations
are added during an uninstall, which is harmless but surprising. An object method on an
instance would be refused with a notice, because WordPress accepts only a function or a
static method.
<?php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
global $wpdb;
delete_option( 'wpsifero_notes_schema' );
delete_option( 'wpsifero_notes_display_settings' );
delete_transient( 'wpsifero_notes_recent' );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}wpsifero_notes" );
The experiment that would have left a table behind is the file copy. On a site that has
only ever updated by copying files, activation ran once, long ago, and every deployment
after that changed code without running any callback. The schema check in
includes/class-plugin.php, hooked to plugins_loaded at an early
priority, is the single piece of code that prevents it.
add_action( 'plugins_loaded', static function () {
if ( get_option( 'wpsifero_notes_schema' ) !== WPSIFERO_NOTES_VERSION ) {
wpsifero_notes_activate( is_multisite() );
}
}, 5 );
Two limitations to leave you with. That check reads an option on every request, so keep
it stored with autoload disabled and keep the comparison to one string; do not put a table
existence query inside it. And the multisite answer here is half given: passing
is_multisite() to a routine that uses $wpdb->prefix still
writes to the current site's tables, so provisioning every site means looping over them,
which is a job for the multisite material rather than one line in an activation hook.
Progress
Mark this lesson done and it stays marked in this browser.