Activation & Deactivation

register_activation_hook, register_deactivation_hook and uninstall.php are the three moments that are not ordinary page loads.

Syntaxregister_activation_hook( string $file, callable $callback )

A plugin has three moments that are not normal requests: activation, deactivation and deletion. Each runs once, in the admin, and each exists for state that outlives a request — creating a table, seeding an option, clearing a schedule, flushing rewrite rules. Everything else belongs on ordinary hooks.

Activation and deactivation

Both registration functions are two-line wrappers around add_action(). Core's implementations are literally these:

register_activation_hook( __FILE__, 'acme_activate' );
// ===
add_action( 'activate_' . plugin_basename( __FILE__ ), 'acme_activate' );

That is why the first argument must be the main plugin file. Pass __FILE__ from an include and the hook name will not match the one core fires. The callback receives one argument — $network_wide on activation, $network_deactivating on deactivation — true when the action came from the network admin on multisite.

Uninstall

Deletion is separate. An uninstall.php in the plugin root is included when the plugin is deleted, and it takes precedence over register_uninstall_hook(). Your plugin is not loaded at that point, so none of your constants, classes or autoloaders exist. Guard the file with WP_UNINSTALL_PLUGIN, which core defines immediately before the include.

Flushing rewrite rules once

flush_rewrite_rules( bool $hard = true ) regenerates the whole rule set and, with $hard true, rewrites .htaccess. Call it on activation only — and register your post types and taxonomies by hand first, because init has not fired for your plugin during the activation request.

Example

Example · php
<?php
/**
 * Plugin Name: Acme Events
 * Version:     1.0.0
 * Text Domain: acme-events
 */

defined( 'ABSPATH' ) || exit;

function acme_events_register_post_type(): void {
	register_post_type(
		'acme_event',
		array(
			'public'      => true,
			'has_archive' => true,
			'rewrite'     => array( 'slug' => 'events' ),
			'supports'    => array( 'title', 'editor' ),
		)
	);
}
add_action( 'init', 'acme_events_register_post_type' );

/**
 * @param bool $network_wide True when activated network-wide from the network admin.
 */
function acme_events_activate( bool $network_wide ): void {
	// 'init' has not fired during the activation request, so the post type is not
	// registered yet. Register it by hand or the flush below writes a rule set
	// with no /events/ rules in it.
	acme_events_register_post_type();
	flush_rewrite_rules();

	// add_option() is a no-op if the option already exists, so reactivating
	// will not stamp on a value the user has changed.
	add_option( 'acme_events_per_page', 10 );
	add_option( 'acme_events_db_version', '1.0.0' );

	if ( ! wp_next_scheduled( 'acme_events_prune' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'acme_events_prune' );
	}
}
register_activation_hook( __FILE__, 'acme_events_activate' );

function acme_events_deactivate(): void {
	// Undo runtime state only. Deactivation is not deletion - never touch user data here.
	wp_clear_scheduled_hook( 'acme_events_prune' );
	flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'acme_events_deactivate' );

When to use it

  • Creating a custom table the first time the plugin is switched on.
  • Making pretty permalinks for a custom post type work immediately after activation instead of after a Permalinks visit.
  • Removing scheduled cron events when the plugin is turned off, without deleting the user's content.
  • Cleaning options, meta and tables when the plugin is deleted, so an uninstall leaves no rows behind.

More examples

uninstall.php

The only correct place to delete user data. Use it rather than register_uninstall_hook(): the hook stores a callback in the uninstall_plugins option and requires it to be a plain function or a static method, whereas uninstall.php needs no registration at all and cannot go stale when you rename a class.

Example · php
<?php
/**
 * Runs when the user deletes the plugin from the Plugins screen.
 * File must sit in the plugin root: acme-events/uninstall.php
 *
 * The plugin itself is NOT loaded here. No constants, no classes,
 * no autoloader - only core is available.
 */

// Core defines this constant immediately before including the file. Without
// the guard, anyone who can reach the URL can run the deletion routine.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

global $wpdb;

delete_option( 'acme_events_per_page' );
delete_option( 'acme_events_db_version' );

// Options are per-site; on multisite each site has its own options table.
if ( is_multisite() ) {
	foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
		switch_to_blog( $site_id );
		delete_option( 'acme_events_per_page' );
		delete_option( 'acme_events_db_version' );
		restore_current_blog();
	}
}

// Post meta added by the plugin, across every post.
delete_post_meta_by_key( '_acme_event_starts_at' );

// Custom table, if the plugin created one. $wpdb->prefix is already escaped.
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}acme_event_signups" );

An upgrade routine, because activation does not run on update

The gap that catches most plugin authors: a user who updates from 1.0 to 1.2 never triggers register_activation_hook, so any migration written there silently never runs. Priority 20 puts it after the post types registered on the default priority of 10, so a flush here picks up their rules.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

const ACME_EVENTS_DB_VERSION = '1.2.0';

/**
 * Runs on every request but does nothing once the stored version matches.
 * Hooked to init, not to the activation hook, because updating a plugin
 * through the admin or WP-CLI never fires activation.
 */
function acme_events_maybe_upgrade(): void {
	$installed = get_option( 'acme_events_db_version', '0' );

	if ( version_compare( $installed, ACME_EVENTS_DB_VERSION, '>=' ) ) {
		return;
	}

	if ( version_compare( $installed, '1.1.0', '<' ) ) {
		// 1.1.0 renamed an option.
		$old = get_option( 'acme_events_limit' );
		if ( false !== $old ) {
			update_option( 'acme_events_per_page', absint( $old ) );
			delete_option( 'acme_events_limit' );
		}
	}

	if ( version_compare( $installed, '1.2.0', '<' ) ) {
		// 1.2.0 added a rewrite slug, so the rule set is stale.
		flush_rewrite_rules();
	}

	update_option( 'acme_events_db_version', ACME_EVENTS_DB_VERSION );
}
add_action( 'init', 'acme_events_maybe_upgrade', 20 );

Multisite-aware activation

Network activation fires the activation hook exactly once, not once per site, so per-site options and tables are created for none of them unless you loop. The wp_initialize_site handler covers sites added later — without it, a network-activated plugin is half-installed on every new site.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

/**
 * @param bool $network_wide True when the plugin was network-activated.
 */
function acme_events_activate( bool $network_wide ): void {
	if ( $network_wide && is_multisite() ) {
		// number => 0 removes the default limit of 100 sites.
		foreach ( get_sites( array( 'fields' => 'ids', 'number' => 0 ) ) as $site_id ) {
			switch_to_blog( $site_id );
			acme_events_activate_single_site();
			restore_current_blog();
		}

		return;
	}

	acme_events_activate_single_site();
}
register_activation_hook( __FILE__, 'acme_events_activate' );

function acme_events_activate_single_site(): void {
	acme_events_register_post_type();
	flush_rewrite_rules();
	add_option( 'acme_events_per_page', 10 );
}

// Sites created after network activation need the same setup.
add_action( 'wp_initialize_site', static function ( WP_Site $site ): void {
	if ( ! is_plugin_active_for_network( plugin_basename( ACME_EVENTS_FILE ) ) ) {
		return;
	}

	switch_to_blog( (int) $site->blog_id );
	acme_events_activate_single_site();
	restore_current_blog();
}, 20 );

Discussion

  • Be the first to comment on this lesson.