Plugin Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

A plugin is the unit of behaviour in WordPress: anything a site does beyond publishing posts and pages, from a book catalogue to a payment gateway, is added by a plugin rather than by editing core. This page is for plugin developers and for administrators who review custom code before deploying it. It explains how WordPress loads plugins, the main file header, code organisation and autoloading, and what runs when a plugin is activated, deactivated, updated and uninstalled, ending with the skeleton of lumen-catalog, the book catalogue plugin of the Lumen Press site, run through its whole life cycle. Hooks have their own page, Hooks: Actions and Filters; installing and updating plugins as an administrator is covered in Choosing and Managing Plugins and Themes.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0, Composer 2.10, and WordPress Coding Standards (WPCS) 3.4.1 on PHP_CodeSniffer 3.13.6, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it.

What a plugin is

A plugin is PHP code, often with JavaScript and CSS, that WordPress loads on every request while it is active. It changes behaviour through hooks and core APIs (post types, blocks, REST routes, settings screens, WP-CLI commands). Core updates do not overwrite it and switching themes does not remove it, which is why anything the site must keep across theme changes is "plugin territory" (see Themes Overview). Regular plugins live in wp-content/plugins/<slug>/ and are activated on Plugins or with WP-CLI; must-use plugins and drop-ins are described in Choosing and Managing Plugins and Themes.

How WordPress loads plugins

A plugin is identified by its basename, the path of its main file relative to wp-content/plugins: lumen-catalog/lumen-catalog.php. The active_plugins option stores the basenames of the active plugins (on Multisite, network-activated ones are in the active_sitewide_plugins network option). activate_plugin() sorts that list, so plugins load in alphabetical order of basename.

wp-settings.php loads must-use plugins (then fires muplugins_loaded), network-activated plugins on Multisite, the site’s plugins from wp_get_active_and_valid_plugins(), then fires plugins_loaded, loads the theme’s functions.php, and fires init. When a plugin’s main file runs, other plugins may not be loaded yet, so code that calls into another plugin waits for plugins_loaded, and the main file only declares things and registers callbacks. Plugin code also runs on every request (front end, admin, REST, cron, WP-CLI), so expensive work sits behind a specific hook or route, never at the top of the main file.

The main plugin file and its header

A plugin needs exactly one file with a header comment, the main plugin file. WordPress scans the PHP files at the top level of wp-content/plugins and one level down in each folder, and treats every file whose comment contains Plugin Name: as a plugin. get_plugin_data() reads the header with get_file_data(), which reads only the first 8 KB of the file, so the header goes at the top. Only Plugin Name is required.

Header Since Meaning

Plugin Name

Long-standing

Required. The name shown on Plugins.

Plugin URI

Long-standing

Home page of the plugin, unique to it; directory plugins may not use a WordPress.org URL.

Description

Long-standing

Short description (under 140 characters).

Version

Long-standing

Compared with PHP’s version_compare(), so 0.10.0 is newer than 0.9.0.

Requires at least

Header 5.3, checked since 5.2

Minimum WordPress version; activation fails below it.

Requires PHP

Header 5.3, checked since 5.2

Minimum PHP version; activation fails below it.

Author, Author URI

Long-standing

Author names (comma-separated) and web site.

License, License URI

Directory convention

Licence slug (GPL-2.0-or-later) and its text. Not parsed by core; the directory accepts only GPL-compatible licences.

Text Domain

Long-standing

The gettext text domain. For directory plugins it must equal the slug.

Domain Path

Long-standing

Folder of bundled translation files, relative to the plugin folder (/languages).

Network

3.0

true allows only network-wide activation on Multisite; omit it otherwise. Replaces the deprecated Site Wide Only.

Update URI

5.8

Where updates come from. Any value other than the plugin’s WordPress.org URL stops WordPress.org updates from overwriting it.

Requires Plugins

6.5

Comma-separated WordPress.org slugs of the plugins this one depends on.

validate_plugin_requirements(), called by activate_plugin(), enforces Requires at least and Requires PHP, reading them from the header only since 5.8 (the readme.txt fallback was removed). With Requires at least raised to 7.3 in a test copy of lumen-catalog, wp plugin activate failed with "Current WordPress version (7.1.3) does not meet minimum requirements for Lumen Catalog. The plugin requires WordPress 7.3." The check runs at activation only.

Requires Plugins (6.5) makes activation fail while a dependency is inactive. It takes slugs (lumen-catalog, not lumen-catalog/lumen-catalog.php), has no version constraints and does not change the load order; the lumen-reviews add-on that requires lumen-catalog is shown in Choosing and Managing Plugins and Themes. One older book says plugins cannot depend on each other, which stopped being true in 6.5. The guard covers activation only, so a dependent still checks at runtime (class_exists(), a version constant). Since 6.7 WordPress.org plugins get translations loaded just in time and translation calls before init trigger a notice; see Internationalization.

Folder structure

A plugin with more than one file lives in a folder named after its slug, with the main file named the same:

lumen-catalog/
├── lumen-catalog.php      # main file: header, guard, autoloader, life-cycle hooks, boot
├── uninstall.php          # runs when the plugin is deleted
├── composer.json          # autoload map and development tools (committed with composer.lock)
├── phpcs.xml.dist         # coding standard configuration (not shipped)
├── src/                   # namespaced classes, autoloaded (Lumen\Catalog\...)
│   ├── Plugin.php
│   └── Installer.php
├── languages/             # Domain Path for bundled translations
├── build/                 # compiled block and admin assets (generated by @wordpress/scripts)
├── vendor/                # Composer output; shipped in the release ZIP, ignored by Git
└── readme.txt             # required only for the Plugin Directory

The Plugin Handbook’s sample uses admin/ and public/ folders; a namespaced src/ serves the same purpose. Code never hard-codes wp-content/plugins (WP_PLUGIN_DIR can move it): plugin_dir_path( __FILE__ ), plugins_url( 'build/index.js', __FILE__ ) and plugin_basename( __FILE__ ) derive paths, URLs and the basename from the main file. A release ZIP leaves out .git, node_modules, tests and tool configuration.

The ABSPATH guard

Every PHP file in a plugin folder can be requested directly (https://lumenpress.test/wp-content/plugins/lumen-catalog/lumen-catalog.php). WordPress is not loaded then, so ABSPATH is undefined, and a file with top-level code stops with defined( 'ABSPATH' ) || exit;. The Plugin Handbook says this only matters for files with code outside classes and functions; lumen-catalog adds it to every file anyway, since it costs nothing. It prevents partial execution and path disclosure; it is not access control. uninstall.php checks WP_UNINSTALL_PLUGIN instead.

Prefixes and namespaces

All plugins share one PHP process and one database. Two plugins declaring the same function cause a fatal error; two using the same option name overwrite each other’s data silently. The Plugin Handbook’s rule is to prefix everything global with a prefix of four or five characters or more that is not a common word, and never wp_, __, _ or WordPress.

PHP namespaces cover classes, interfaces, traits, functions and const constants: lumen-catalog puts its classes in Lumen\Catalog. They do not cover names that WordPress stores or looks up as strings, which still take a prefix: define() constants (LUMEN_CATALOG_VERSION), global variables ($lumen_catalog_site_id), options, transients and cron hooks (lp_catalog_settings, lp_catalog_sync_stock), custom hooks, post types, taxonomies and meta keys (lp_book, lp_genre, lp_isbn; post type names are limited to 20 characters), tables (lp_stock), script handles, and block and REST namespaces (lumen/book-card, lumen/v1).

Wrapping every function in function_exists() is not a substitute: if another plugin defined the name first, the plugin silently runs foreign code. WPCS enforces prefixes with WordPress.NamingConventions.PrefixAllGlobals, configured in the ruleset below.

Composer autoloading

A namespaced plugin lets Composer generate the class loader instead of a list of require_once statements: the PSR-4 rule "Lumen\\Catalog\\": "src/" maps Lumen\Catalog\Installer to src/Installer.php. Composer itself is explained in Composer and Packages; what is specific to plugins:

  • WordPress does not run Composer. The release ZIP contains vendor/, built with composer install --no-dev --optimize-autoloader; WPCS and test tools stay in require-dev. A missing vendor/ is a deploy error, so the main file checks for the autoloader and shows an admin notice instead of breaking every page.

  • Resolve for the minimum PHP. config.platform.php set to 7.4.33 makes Composer pick versions that run on the oldest PHP in the header.

  • Bundled libraries collide. Two plugins shipping different versions of one library share one class namespace, and whichever loads first wins; plugins that bundle libraries rename their namespaces at build time with a scoping tool such as PHP-Scoper or Strauss.

Activation, deactivation and uninstall

Event Registered with Core actions What belongs there

Activation

register_activation_hook( __FILE__, $callback )

activate_plugin, activate_{basename} (the callback), activated_plugin

Tables, default options, roles; register content types and flush rewrite rules once

Deactivation

register_deactivation_hook( __FILE__, $callback )

deactivate_plugin, deactivate_{basename}, deactivated_plugin

Unschedule cron events, flush rewrite rules, clear caches. Never delete data.

Uninstall

uninstall.php or register_uninstall_hook()

pre_uninstall_plugin, uninstall_{basename} (hook method only), then delete_plugin and deleted_plugin as files are removed

Delete options and tables; content only as an opt-in

Update

Nothing

upgrader_process_complete, which runs the old code still in memory

A versioned upgrade routine, run on a later request

Activation

register_activation_hook() must run at the top level of the main file, never inside a plugins_loaded or init callback: on activation WordPress includes the plugin file after those hooks, runs activate_{basename} and redirects, so only activated_plugin and shutdown follow. In the dashboard the file is first included by plugin_sandbox_scrape(): a fatal error leaves the plugin inactive, and any output (a space before <?php, a line after a closing ?>) is reported as "unexpected output during activation" and would break redirects and REST responses on every request.

Activation does not run when the plugin is updated (the rule since 3.1, so single and bulk updates behave the same), when code is deployed by Git, rsync or SFTP, or for must-use plugins, so every setup step also lives in the upgrade routine. On Multisite, network activation runs the callback once, for the current site, with $network_wide set to true; lumen-catalog lets its upgrade routine set up each site the first time the plugin runs there.

Deactivation

Deactivation is reversible (troubleshooting, failed updates, recovery mode), and an administrator expects to reactivate and find everything as before, so it never deletes data. The callback stops scheduled work (wp_clear_scheduled_hook()), removes the plugin’s rewrite rules and clears its caches.

Uninstall

Uninstall runs when an administrator deletes an inactive plugin on Plugins, or with wp plugin uninstall (wp plugin delete removes files without it). It is provided in one of two ways:

uninstall.php

A file at the top level of the plugin folder. uninstall_plugin() defines WP_UNINSTALL_PLUGIN and includes it; the main file is not loaded, so uninstall.php loads what it needs (here, the autoloader). It checks the constant first. If it exists it takes precedence over a registered hook.

register_uninstall_hook( __FILE__, $callback )

Stores the callback in the uninstall_plugins option. It must be a function name or a static method. Object methods are rejected with _doing_it_wrong; a closure passes that check but cannot be serialized into the option, so PHP throws "Serialization of 'Closure' is not allowed". WordPress includes the main file to call it, so top-level code there runs too, and WP_UNINSTALL_PLUGIN is not defined.

Whether uninstall deletes content is a product decision: the lp_book posts are the publisher’s work, so lumen-catalog removes only its options and table. A plugin that does delete content makes it opt-in and uses wp_delete_post( $id, true ), which also removes meta, terms and revisions. On Multisite, uninstall runs once and the plugin loops over sites itself.

Flushing rewrite rules on activation only

A post type with 'rewrite' => array( 'slug' => 'books' ) adds rules such as books/?$ (the archive) and books/([^/]+)/?$ (a single book). Compiled rules live in the rewrite_rules option and are rebuilt only on request; without a flush after activation, https://lumenpress.test/books/ returns 404 until someone saves Settings > Permalinks. flush_rewrite_rules() regenerates every rule, writes the option and, with the default $hard = true, rewrites .htaccess; calling it on init, as some tutorials do, repeats that on every request. Activation registers the post type first (its init callback has not run in that request) and then flushes; deactivation calls unregister_post_type() first and then flushes; a later slug change flushes once from the upgrade routine. lumen-catalog uses a soft flush (flush_rewrite_rules( false )), since it needs nothing in .htaccess. See Rewrite API and Permalinks.

Versioned upgrade routines

Data changes that a new version needs (a table column, a renamed option, a capability) run from an upgrade routine. A schema version, an integer independent of the Version header, is stored in an option. On every request (plugins_loaded) the routine compares it with the version the code expects, at the cost of one autoloaded option read; if the stored value is lower, it runs each newer migration in order and stores the new version. Activation calls the same routine, so a new install and an update reach the same state.

Two requests can start the same upgrade at once, so migrations must be idempotent. dbDelta() helps: it compares a CREATE TABLE statement with the existing table and adds what is missing. It is strict: one column per line, two spaces between PRIMARY KEY and its column list, KEY rather than INDEX, and the charset from $wpdb->get_charset_collate(). It never drops columns; that takes an explicit ALTER TABLE in its own migration. $wpdb and custom tables are covered in WP_Query and the Database API.

upgrader_process_complete, sometimes suggested instead, runs the previous version’s code still in memory (the Code Reference warns about it) and never fires for deploys outside the upgrader.

The lumen-catalog skeleton

The other Plugin Development pages extend this skeleton (taxonomies, meta, settings, shortcode, REST routes). Its upgrade routine has two migrations: version 1 creates the {prefix}lp_stock table, the stock movement history the stock synchronisation job appends to (one row per change; the current level of each book is its lp_stock post meta), and version 2 moves a legacy option into the settings array.

composer.json

{
	"name": "lumenpress/lumen-catalog",
	"type": "wordpress-plugin",
	"license": "GPL-2.0-or-later",
	"require": {
		"php": ">=7.4"
	},
	"require-dev": {
		"wp-coding-standards/wpcs": "3.4.1"
	},
	"autoload": {
		"psr-4": {
			"Lumen\\Catalog\\": "src/"
		}
	},
	"config": {
		"platform": {
			"php": "7.4.33"
		},
		"optimize-autoloader": true,
		"allow-plugins": {
			"dealerdirect/phpcodesniffer-composer-installer": true
		}
	}
}

WPCS 3.4.1 resolved to PHP_CodeSniffer 3.13.6, PHPCSUtils 1.2.3, PHPCSExtra 1.5.1 and the Composer installer plugin 1.2.1, which config.allow-plugins must allow because it registers WPCS with PHP_CodeSniffer. Composer validated the file (composer validate).

lumen-catalog.php

<?php
/**
 * Plugin Name:       Lumen Catalog
 * Plugin URI:        https://lumenpress.example/lumen-catalog/
 * Description:       Book catalogue for Lumen Press: the lp_book post type, its taxonomies, meta and REST API.
 * Version:           0.1.0
 * Requires at least: 6.9
 * Requires PHP:      7.4
 * Author:            Lumen Press
 * Author URI:        https://lumenpress.example/
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       lumen-catalog
 * Domain Path:       /languages
 * Update URI:        https://plugins.lumenpress.example/lumen-catalog/
 *
 * @package Lumen\Catalog
 */

use Lumen\Catalog\Installer;
use Lumen\Catalog\Plugin;

defined( 'ABSPATH' ) || exit;

define( 'LUMEN_CATALOG_VERSION', '0.1.0' );
define( 'LUMEN_CATALOG_FILE', __FILE__ );

// Release ZIP: the plugin's own vendor/. Composer-managed site: the site's autoloader already knows the classes.
if ( ! class_exists( Plugin::class ) ) {
	if ( ! is_readable( __DIR__ . '/vendor/autoload.php' ) ) {
		add_action(
			'admin_notices',
			static function () {
				echo '<div class="notice notice-error"><p>';
				echo esc_html__( 'Lumen Catalog is missing its vendor/ directory. Run composer install.', 'lumen-catalog' );
				echo '</p></div>';
			}
		);
		return;
	}
	require_once __DIR__ . '/vendor/autoload.php';
}

register_activation_hook( __FILE__, array( Installer::class, 'activate' ) );
register_deactivation_hook( __FILE__, array( Installer::class, 'deactivate' ) );

add_action( 'plugins_loaded', array( Plugin::class, 'boot' ) );

The header uses the canonical values shared by every page about Lumen Press. lumen-catalog depends on no other plugin, so it has no Requires Plugins line (its add-ons have one), and no Network line, so a network may activate it per site or network-wide. Update URI names the publisher’s update server (see Update URI and private updates).

src/Plugin.php

<?php
/**
 * Plugin bootstrap.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Wires the plugin's services to WordPress hooks.
 */
final class Plugin {

	/**
	 * Runs once per request on plugins_loaded.
	 */
	public static function boot(): void {
		Installer::maybe_upgrade();

		add_action( 'init', array( self::class, 'register_post_types' ) );
	}

	/**
	 * Registers lp_book (taxonomies and meta are added later). Also called on activation.
	 */
	public static function register_post_types(): void {
		register_post_type(
			'lp_book',
			array(
				'label'        => __( 'Books', 'lumen-catalog' ),
				'public'       => true,
				'show_in_rest' => true,
				'has_archive'  => true,
				'rewrite'      => array( 'slug' => 'books' ),
				'supports'     => array( 'title', 'editor', 'excerpt', 'thumbnail', 'custom-fields', 'revisions' ),
			)
		);
	}
}

Labels, taxonomies and the rest of the registration are in Custom Post Types and Taxonomies. 'show_in_rest' => true gives lp_book the block editor and a REST endpoint; a classic-theme book’s example omits it.

src/Installer.php

<?php
/**
 * Activation, deactivation, upgrade and uninstall routines.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Owns everything the plugin stores outside its own files.
 */
final class Installer {

	const DB_VERSION     = 2; // Schema version this code expects; bump it with every migration.
	const VERSION_OPTION = 'lumen_catalog_db_version';

	/**
	 * Activation: install or upgrade the data, then rebuild the rewrite rules once.
	 */
	public static function activate(): void {
		self::upgrade();

		// The post type must exist before the rules are rebuilt, or /books/ returns 404.
		Plugin::register_post_types();
		flush_rewrite_rules( false );
	}

	/**
	 * Deactivation: stop scheduled work and drop the rules. Never delete data here.
	 */
	public static function deactivate(): void {
		wp_clear_scheduled_hook( 'lp_catalog_sync_stock' );

		unregister_post_type( 'lp_book' );
		flush_rewrite_rules( false );
	}

	/**
	 * Runs on every request (one autoloaded option read) and upgrades when needed.
	 */
	public static function maybe_upgrade(): void {
		if ( (int) get_option( self::VERSION_OPTION, 0 ) < self::DB_VERSION ) {
			self::upgrade();
		}
	}

	/**
	 * Applies every migration newer than the installed schema version, in order.
	 */
	public static function upgrade(): void {
		$installed = (int) get_option( self::VERSION_OPTION, 0 );

		if ( $installed < 1 ) {
			self::create_tables();
			add_option( 'lp_catalog_settings', array( 'isbn_prefix' => '978' ) );
		}

		if ( $installed < 2 ) {
			// 0.1.0 replaced the lumen_catalog_isbn_prefix option with a settings key.
			$settings                = (array) get_option( 'lp_catalog_settings', array() );
			$settings['isbn_prefix'] = (string) get_option( 'lumen_catalog_isbn_prefix', $settings['isbn_prefix'] ?? '978' );
			update_option( 'lp_catalog_settings', $settings );
			delete_option( 'lumen_catalog_isbn_prefix' );
		}

		update_option( self::VERSION_OPTION, self::DB_VERSION, true );
	}

	/**
	 * Creates or updates the stock movement history table with dbDelta(), which is safe to run again.
	 * The current stock of a book is its lp_stock post meta; this table keeps one row per change.
	 */
	private static function create_tables(): void {
		global $wpdb;
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$table   = $wpdb->prefix . 'lp_stock';
		$charset = $wpdb->get_charset_collate();

		dbDelta(
			"CREATE TABLE {$table} (
  id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  book_id bigint(20) unsigned NOT NULL,
  distributor_sku varchar(64) NOT NULL DEFAULT '',
  delta int(11) NOT NULL DEFAULT '0',
  quantity int(11) NOT NULL DEFAULT '0',
  changed_at datetime NOT NULL,
  PRIMARY KEY  (id),
  KEY book_changed (book_id,changed_at)
) {$charset};"
		);
	}

	/**
	 * Uninstall: remove the plugin's options and table from one site.
	 */
	public static function uninstall_site(): void {
		global $wpdb;

		delete_option( self::VERSION_OPTION );
		delete_option( 'lp_catalog_settings' );
		$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}lp_stock" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}
}

activate() declares no parameter; PHP ignores the $network_wide argument core passes. deactivate() clears the stock synchronisation event scheduled in WP-Cron and Scheduled Tasks (clearing an unscheduled event is harmless). The DROP TABLE has no user input, so $wpdb->prepare() adds nothing and the phpcs:ignore names the two sniffs it silences; queries with variables always go through $wpdb->prepare() (see Security for Developers).

uninstall.php

<?php
/**
 * Runs when an administrator deletes the plugin from the Plugins screen or with WP-CLI.
 *
 * @package Lumen\Catalog
 */

if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}

if ( ! class_exists( Lumen\Catalog\Installer::class ) ) {
	require_once __DIR__ . '/vendor/autoload.php';
}

if ( is_multisite() ) {
	foreach ( get_sites( array( 'fields' => 'ids' ) ) as $lumen_catalog_site_id ) {
		switch_to_blog( $lumen_catalog_site_id );
		Lumen\Catalog\Installer::uninstall_site();
		restore_current_blog();
	}
} else {
	Lumen\Catalog\Installer::uninstall_site();
}

get_sites() returns 100 sites by default; larger networks pass a higher number or page through them.

phpcs.xml.dist

<?xml version="1.0"?>
<ruleset name="Lumen Catalog">
	<file>.</file>
	<exclude-pattern>/vendor/*</exclude-pattern>

	<config name="minimum_wp_version" value="6.9"/>

	<rule ref="WordPress"/>

	<!-- PSR-4 file names (src/Plugin.php) instead of class-plugin.php. -->
	<rule ref="WordPress.Files.FileName">
		<exclude-pattern>/src/*</exclude-pattern>
	</rule>

	<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
		<properties>
			<property name="prefixes" type="array">
				<element value="Lumen\Catalog"/>
				<element value="lumen_catalog"/>
			</property>
		</properties>
	</rule>
</ruleset>

The WordPress standard expects class files named class-plugin.php; with PSR-4 names, the plain WordPress standard reports WordPress.Files.FileName.NotHyphenatedLowercase and InvalidClassFileName for each class file, so the ruleset excludes that sniff for src/ only.

Building and checking the plugin

The PHP tools run in containers, so the host needs only Docker. From the plugin folder:

docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app composer:2 composer dump-autoload
docker run --rm -v "$PWD":/app -w /app composer:2 vendor/bin/phpcs
docker run --rm -v "$PWD":/app -w /app php:8.4-cli sh -c 'for f in *.php src/*.php; do php -l "$f"; done'

PHP_CodeSniffer reported nothing (exit code 0) and php -l found no syntax errors. The development autoloader lists 56 classes, PHP_CodeSniffer’s included; the release build (composer install --no-dev --optimize-autoloader) has three classmap entries, the two plugin classes and Composer’s InstalledVersions.

Running the life cycle with WP-CLI

The checks ran on WordPress 7.1.3 in Docker (wordpress:7.1.3-php8.4-apache, MariaDB 11.8, wordpress:cli-2.12.0-php8.4) at https://lumenpress.test with /%postname%/ permalinks (the stack of Local Development Environments). The release build was copied to wp-content/plugins/, and a development-only must-use plugin logged the life-cycle actions, which WP-CLI prints:

<?php
/**
 * Plugin Name: Lumen Lifecycle Log (development only)
 * Description: Writes every plugin lifecycle hook to the PHP error log.
 *
 * @package LumenLifecycleLog
 */

defined( 'ABSPATH' ) || exit;

foreach ( array( 'activate_plugin', 'activated_plugin', 'deactivate_plugin', 'deactivated_plugin', 'pre_uninstall_plugin', 'delete_plugin', 'deleted_plugin' ) as $lumen_log_hook ) {
	add_action(
		$lumen_log_hook,
		static function ( $plugin ) use ( $lumen_log_hook ) {
			error_log( sprintf( '[lifecycle] %s %s', $lumen_log_hook, $plugin ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	);
}

Activation and deactivation

A legacy option is added first, so that migration 2 has something to move:

wp option add lumen_catalog_isbn_prefix 979
wp plugin activate lumen-catalog
wp option get lumen_catalog_db_version
wp option get lp_catalog_settings --format=json
wp db query "SHOW TABLES LIKE 'lp_lp_stock'" --skip-column-names
wp rewrite list --format=csv --fields=match | grep -c '^books/'
wp plugin deactivate lumen-catalog
wp rewrite list --format=csv --fields=match | grep -c '^books/'
wp option get lumen_catalog_db_version
Success: Added 'lumen_catalog_isbn_prefix' option.
[28-Oct-2026 14:37:47 UTC] [lifecycle] activate_plugin lumen-catalog/lumen-catalog.php
[28-Oct-2026 14:37:47 UTC] [lifecycle] activated_plugin lumen-catalog/lumen-catalog.php
Plugin 'lumen-catalog' activated.
Success: Activated 1 of 1 plugins.
2
{"isbn_prefix":"979"}
lp_lp_stock
18
[28-Oct-2026 14:37:50 UTC] [lifecycle] deactivate_plugin lumen-catalog/lumen-catalog.php
[28-Oct-2026 14:37:50 UTC] [lifecycle] deactivated_plugin lumen-catalog/lumen-catalog.php
Plugin 'lumen-catalog' deactivated.
Success: Deactivated 1 of 1 plugins.
0
2

Activation ran both migrations (the legacy option no longer exists), created the table and wrote eighteen rules starting with books/ (archive, feeds, pagination, single books, attachments, embeds, trackbacks); a published book answered HTTP 200 at https://lumenpress.test/books/the-lighthouse-ledger/ without anyone saving the permalink settings. Deactivation removed the rules and kept the data.

An update, simulated

Setting the stored schema version back to 1 and recreating the legacy option, in one request, reproduces a site whose code was replaced by a version with DB_VERSION = 2. No activation is involved:

wp plugin activate lumen-catalog
wp eval 'update_option( "lumen_catalog_db_version", 1 ); update_option( "lumen_catalog_isbn_prefix", "977" );'
wp option get lumen_catalog_db_version
wp option get lp_catalog_settings --format=json

The two wp option get commands printed 2 and {"isbn_prefix":"977"}, and lumen_catalog_isbn_prefix no longer existed: the first of them loaded WordPress, plugins_loaded found version 1, and migration 2 ran before the value was printed.

Uninstall

wp plugin uninstall lumen-catalog --deactivate
wp option list --search='lumen_catalog*' --format=count
wp db query "SHOW TABLES LIKE 'lp_lp_stock'" --skip-column-names
wp db query "SELECT COUNT(*) FROM lp_posts WHERE post_type = 'lp_book'" --skip-column-names
Deactivating 'lumen-catalog'...
[28-Oct-2026 14:37:54 UTC] [lifecycle] deactivate_plugin lumen-catalog/lumen-catalog.php
[28-Oct-2026 14:37:54 UTC] [lifecycle] deactivated_plugin lumen-catalog/lumen-catalog.php
Plugin 'lumen-catalog' deactivated.
[28-Oct-2026 14:37:54 UTC] [lifecycle] pre_uninstall_plugin lumen-catalog/lumen-catalog.php
[28-Oct-2026 14:37:54 UTC] [lifecycle] delete_plugin lumen-catalog/lumen-catalog.php
[28-Oct-2026 14:37:54 UTC] [lifecycle] deleted_plugin lumen-catalog/lumen-catalog.php
Uninstalled and deleted 'lumen-catalog' plugin.
Success: Uninstalled 1 of 1 plugins.
0
1

Without --deactivate, WP-CLI refuses to uninstall an active plugin ("The 'lumen-catalog' plugin is active."). It deletes the files after uninstall.php has run (--skip-delete keeps them). No option and no table remain, and the book is still in lp_posts.

On a network (after wp core multisite-convert and wp site create --slug=kids), wp plugin activate lumen-catalog --network created only lp_lp_stock. The first command run against the Kids site (wp option get lumen_catalog_db_version --url=lumenpress.test/kids/) printed 2 and created lp_2_lp_stock through maybe_upgrade(), and wp plugin uninstall lumen-catalog --deactivate dropped both tables. See Multisite Development.

Update URI and private updates

WordPress checks for plugin updates by sending the installed plugins to api.wordpress.org, so a private plugin whose slug matched a directory plugin used to risk being "updated" with unrelated code. Since 5.8, an Update URI other than https://wordpress.org/plugins/{slug}/ or w.org/plugin/{slug} (or Update URI: false) stops WordPress.org updates for the plugin. The same release added the update_plugins_{$hostname} filter: for each plugin with an Update URI and no WordPress.org update, wp_update_plugins() applies the filter named after the URI’s host, here update_plugins_plugins.lumenpress.example. A callback returning an array with at least version makes an update appear; package is the ZIP to install. Lumen Press answers it from a must-use plugin:

add_filter(
	'update_plugins_plugins.lumenpress.example',
	static function ( $update, $plugin_data, $plugin_file ) {
		if ( 'lumen-catalog/lumen-catalog.php' !== $plugin_file ) {
			return $update;
		}

		$response = wp_remote_get( 'https://plugins.lumenpress.example/lumen-catalog/update.json', array( 'timeout' => 5 ) );
		if ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
			return $update;
		}

		// Expected keys: version, package (a ZIP URL), url, tested, requires_php.
		$info = json_decode( wp_remote_retrieve_body( $response ), true );

		return is_array( $info ) ? $info : $update;
	},
	10,
	3
);

With the HTTP request answered locally (a pre_http_request filter returning version 0.2.0), wp_update_plugins() stored the entry in the update_plugins site transient with new_version 0.2.0, plus the id (the Update URI) and plugin (the basename) that core adds, so Dashboard > Updates, wp plugin update and automatic updates treat it like any other update. The update server should use HTTPS and serve a ZIP with a single lumen-catalog/ folder. Self-updating code is not allowed in the Plugin Directory, whose guidelines reserve updates to WordPress.org; see Publishing to the Plugin Directory.

Pitfalls and corrections to older books

Pitfall or outdated advice Instead

Plugins cannot depend on other plugins (one older book)

Requires Plugins (6.5) declares dependencies; activation fails while they are inactive.

Flushing rewrite rules or calling add_role() on every init (a book and many tutorials)

Both write to the database. Run them once, on activation or from the upgrade routine.

A custom post type registered without show_in_rest (a classic-theme book)

Without it the post type gets the classic editor, no REST endpoint, and no Block Bindings for its meta.

Deleting options or tables on deactivation

Deactivation is temporary; delete data on uninstall.

Migrating data in the activation hook only

Activation does not run on updates (since 3.1); compare a stored schema version on plugins_loaded.

Unprefixed names, or function_exists() around every function

Prefix every global name; namespaces do not cover options, hooks and post types.

WordPress All-in-One For Dummies covers using plugins, not writing them; Pro WordPress and Ultimate WordPress Handbook show plugin code built around classic themes.