A plugin's life has three events WordPress lets you react to: it is activated, it is deactivated, and it is deleted. Each has a different job. Get them wrong and you leave orphaned cron events firing for years, or worse, delete a client's bookings because they switched the plugin off for five minutes to debug something.
Three events, three jobs
| Event | When | Job | Never |
|---|---|---|---|
| Activation | The user clicks Activate | Create tables, add capabilities, set default options, schedule events, flush rewrites | Print output, assume it runs on updates |
| Deactivation | The user clicks Deactivate | Stop scheduled work, flush rewrites | Delete data: deactivation is often temporary |
| Uninstall | The user deletes the plugin | Remove options, tables, capabilities and, if asked, content | Run without checking it is a real uninstall |
Registering activation and deactivation
Both hooks are registered in the main file, at the top level, because they identify the plugin by __FILE__:
// wp-content/plugins/myplugin/myplugin.php
require_once MYPLUGIN_PATH . 'includes/install.php';
register_activation_hook( __FILE__, 'myplugin_activate' );
register_deactivation_hook( __FILE__, 'myplugin_deactivate' );
Two rules catch people out:
- Do not register them inside another hook, such as
plugins_loadedorinit. Activation happens on a special request where the plugin file is loaded and the activation action fires straight away; a registration inside a later hook is too late. - The callback must exist when the hook fires. Load the file that defines it before registering, as above.
Default settings in one place
Activation needs the plugin's default settings, and so will the settings screen later. Define them once:
<?php
// wp-content/plugins/myplugin/includes/helpers.php
defined( 'ABSPATH' ) || exit;
/**
* Default values for the myplugin_settings option.
*/
function myplugin_default_settings(): array {
return array(
'events_per_page' => 10,
'currency' => 'GBP',
'max_guests' => 5,
'delete_data' => false,
);
}
/**
* Current settings, with defaults for any missing keys.
*/
function myplugin_get_settings(): array {
$saved = get_option( 'myplugin_settings', array() );
return wp_parse_args( is_array( $saved ) ? $saved : array(), myplugin_default_settings() );
}
The activation routine
<?php
// wp-content/plugins/myplugin/includes/install.php
defined( 'ABSPATH' ) || exit;
/**
* Runs when the plugin is activated.
*
* @param bool $network_wide True when network-activated on Multisite.
*/
function myplugin_activate( bool $network_wide = false ): void {
// 1. Database tables. dbDelta() is safe to run again.
myplugin_install_tables();
// 2. Capabilities, added once and stored with the role.
$admin = get_role( 'administrator' );
if ( $admin ) {
$admin->add_cap( 'myplugin_manage_bookings' );
}
// 3. Default settings, without overwriting existing ones.
add_option( 'myplugin_settings', myplugin_default_settings() );
// 4. Scheduled work, without creating duplicates.
if ( ! wp_next_scheduled( 'myplugin_daily_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'myplugin_daily_cleanup' );
}
// 5. Rewrite rules for the events post type.
myplugin_register_post_types();
flush_rewrite_rules();
}
myplugin_install_tables() is the dbDelta() function from the WordPress course's $wpdb lesson. The custom tables lesson in this course improves it.
Every step is idempotent: running it twice has the same effect as running it once. That matters because users deactivate and reactivate plugins all the time.
add_option()does nothing if the option exists, so reactivating never resets a user's settings.update_option()here would.wp_next_scheduled()stops a second copy of the daily event.add_cap()anddbDelta()are safe to repeat.
The rewrite flush works as described in the WordPress course: on the activation request your init callback has not run yet, so the routine registers the post type itself before flushing.
Stopping activation cleanly
If the plugin cannot work, for example because a PHP extension is missing, say so and stop:
// wp-content/plugins/myplugin/includes/install.php, at the start of myplugin_activate().
if ( ! extension_loaded( 'intl' ) ) {
wp_die(
esc_html__( 'My Plugin needs the PHP intl extension. Ask your host to enable it, then activate the plugin again.', 'myplugin' ),
esc_html__( 'Plugin not activated', 'myplugin' ),
array( 'back_link' => true )
);
}
wp_die() during activation stops the request before WordPress marks the plugin active. PHP and WordPress version checks are already handled by the Requires PHP and Requires at least headers, so you only need this for other requirements.
Never echo during activation. Output on the activation request triggers the "generated unexpected output" warning and can break the redirect back to the Plugins screen. If you want to welcome the user, set an option during activation and show an admin notice on the next request. The admin notices lesson shows how.
Activation does not run on updates
When a user updates the plugin, from the dashboard, with WP-CLI or by deploying new files, the activation hook does not run. Anything that must happen after an update, such as adding a column to the bookings table, needs a version check that runs on every load. You will build one in the custom tables lesson, and the data migrations lesson extends it. Design activation so the same functions can be called from that upgrade path.
Deactivation
<?php
// wp-content/plugins/myplugin/includes/install.php
/**
* Runs when the plugin is deactivated. Keeps all data.
*/
function myplugin_deactivate(): void {
wp_clear_scheduled_hook( 'myplugin_daily_cleanup' );
unregister_post_type( 'myplugin_event' );
flush_rewrite_rules();
}
Stop anything that would keep running without your code: cron events would otherwise fire hooks that nobody listens to. Flush rewrites so event URLs return a clean 404 instead of a broken template.
Leave tables, options, capabilities and content alone. A user who deactivates to test a conflict expects everything back when they reactivate.
Uninstall
WordPress offers two ways to run code when a plugin is deleted: an uninstall.php file in the plugin's root folder, or register_uninstall_hook(). Use the file. It is easier to find, it does not need the rest of the plugin to load, and if both exist WordPress uses the file.
<?php
// wp-content/plugins/myplugin/uninstall.php
// Only run when WordPress is uninstalling this plugin.
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
$myplugin_settings = get_option( 'myplugin_settings', array() );
// Always remove things that are useless without the plugin.
delete_option( 'myplugin_db_version' );
wp_clear_scheduled_hook( 'myplugin_daily_cleanup' );
$myplugin_admin = get_role( 'administrator' );
if ( $myplugin_admin ) {
$myplugin_admin->remove_cap( 'myplugin_manage_bookings' );
}
// Content and records only go if the user opted in.
if ( empty( $myplugin_settings['delete_data'] ) ) {
return;
}
global $wpdb;
$wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'myplugin_bookings' ) );
$myplugin_event_ids = $wpdb->get_col(
$wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", 'myplugin_event' )
);
foreach ( $myplugin_event_ids as $myplugin_event_id ) {
wp_delete_post( (int) $myplugin_event_id, true );
}
delete_metadata( 'user', 0, 'myplugin_favourites', '', true );
delete_metadata( 'user', 0, 'myplugin_dismissed_notices', '', true );
delete_option( 'myplugin_settings' );
Points to notice:
WP_UNINSTALL_PLUGINis only defined when WordPress runs the file as part of deleting the plugin. The check stops anyone running it by URL.uninstall.phpruns in the global scope, so its variables are global. Prefix them like everything else.- The plugin is not active during uninstall. Your post type and taxonomies are not registered, so the code finds events with a direct query by
post_typeand then letswp_delete_post()remove each post with its meta and term relationships. delete_metadata()with$delete_allset totrueremoves a meta key from every user in one call.- The settings option is deleted last, because the code reads
delete_datafrom it first.
Ask before deleting content
Whether to delete content on uninstall is a product decision, and the safe default is no. Users delete and reinstall plugins to fix problems, and site owners do not expect "Delete" to wipe two years of bookings. The delete_data setting, which you add to the settings screen in the Settings API lesson, puts the choice in the user's hands. Settings, caches and capabilities can always go, because they are useless without the plugin.
On a site with tens of thousands of events, deleting every post in one uninstall request can hit PHP's time limit. Large plugins delete in batches, or tell users to export first. On Multisite, uninstall.php runs once, for the main site; the Multisite lesson shows how to clean every site.
Summary
- Register activation and deactivation hooks in the main file, at the top level, with
__FILE__. - Activation creates tables, capabilities, default options and scheduled events, and flushes rewrites; make every step safe to repeat.
- Activation never runs on updates; build an upgrade check that reuses the same functions.
- Deactivation stops scheduled work and flushes rewrites, but keeps all data.
uninstall.phpchecksWP_UNINSTALL_PLUGIN, removes options, capabilities and events, and deletes content only when the user opted in.