Modern enterprise plugin architecture in WordPress 6.7–7.0+ ("Armstrong") has evolved far beyond the monolithic procedural files of the PHP 5/7 era. The production standard for plugin engineering relies on five architectural invariants:
Requires Plugins: Header): A native WordPress mechanism (introduced in WP 6.5, standardized in WP 7.0) allowing plugins to declare mandatory parent plugin dependencies in their main file header (Requires Plugins: woocommerce, advanced-custom-fields). The core automatically enforces dependency order, preventing child plugin activation or parent plugin deactivation without manual is_plugin_active() checks.PHP-Scoper / Strauss): The plugin codebase is structured within src/ following Composer PSR-4 conventions, while third-party dependencies inside vendor/ are prefixed with a unique vendor namespace (AgenticLab\Vendor\...) to prevent fatal class collision issues across plugins.dbDelta(): Custom MySQL table provisioning and updates execute during plugin activation or automated version upgrades (SCHEMA_VERSION tracked in wp_options), adhering strictly to the parser syntax rules of dbDelta.Activation $\neq$ Deactivation $\neq$ Uninstall):
register_activation_hook: environment validation, dbDelta migrations, role capability provisioning, and soft permalink flushing (flush_rewrite_rules(false)).register_deactivation_hook: cancelling scheduled background cron workers (Action Scheduler / WP-Cron) and purging transient caches (never dropping customer data!).uninstall.php: an isolated teardown script triggered during complete plugin deletion (defined('WP_UNINSTALL_PLUGIN')) to purge custom tables, options, and metadata.View the plugin lifecycle as a deterministic finite-state machine governing installation, runtime execution, and uninstallation, where each phase operates under strict side-effect boundaries.
Requires Plugins Header PHP-Scoper / Strauss dbDelta Idempotent Activation Isolated uninstall.php[1. ZIP Upload / Git Deploy]
│
▼
[2. Header Verification by WP 7.0+ Core]
├── Requires PHP: 8.4
├── Requires at least: 6.7
└── Requires Plugins: woocommerce ──► If missing: Activation Blocked in UI!
│
▼
[3. Activation Phase: register_activation_hook]
├── Run table migrations via dbDelta()
├── Persist schema version: update_option('agentic_db_version', '2.1.0')
└── flush_rewrite_rules(false)
│
▼
[4. Standard Runtime: plugins_loaded hook]
├── Load vendor/scoper-autoload.php (PSR-4)
└── Verify database schema version (Auto-Upgrade on Git/CI deployment)
│
▼
[5. Deactivation: register_deactivation_hook]
├── wp_clear_scheduled_hook('agentic_cron_sync')
└── User data and tables remain strictly INTACT!
│
▼
[6. Complete Removal: uninstall.php]
├── Verify: if (!defined('WP_UNINSTALL_PLUGIN')) exit;
└── DROP TABLE + delete_option + cache flushCore validates Requires PHP, Requires at least, and Requires Plugins headers before running plugin code.
Load source classes from src/ and isolated dependencies from build/vendor/ without polluting the global namespace.
Inspect live MySQL table definitions, apply column and index diffs via dbDelta, and store schema version.
Clear periodic WP-Cron schedules and purge transient caches while preserving all persistent user data.
Isolated script execution upon deletion: drops custom tables and deletes plugin options.
PHP-Scoper): Scoping shared libraries (Guzzle, Symfony, Monolog, Carbon) into dedicated vendor namespaces ensures your plugin runs seamlessly alongside hundreds of third-party plugins.register_activation_hook, verifying SCHEMA_VERSION on plugins_loaded automatically triggers dbDelta() upon detecting newer code.{$wpdb->prefix}agentic_telemetry) with optimized composite B-Tree indexes.is_multisite()): Correctly iterating through and provisioning tables across all network sites via get_sites(['fields' => 'ids']) and switch_to_blog().Contrast legacy procedural plugin layouts with modern modular architecture in WordPress 7.0+:
dbDelta()#The dbDelta() function in wp-admin/includes/upgrade.php evaluates SQL strings with regular expressions against DESCRIBE table_name outputs. Violating any of these syntax rules causes migrations to be silently ignored:
PRIMARY KEY and the opening parenthesis (, there MUST be exactly two spaces: PRIMARY KEY (id).KEY instead of INDEX, and always supply an index name: KEY event_created_idx (event_type, created_at).IF NOT EXISTS inside CREATE TABLE and omit backticks (`) around column names.Below is a production-grade bootstrap file and uninstaller in PHP 8.4+ featuring a lightweight native PSR-4 autoloader, versioned dbDelta migration engine, and WordPress Multisite support.
<?php
/**
* Plugin Name: Fullstack Agentic Lab — Core Infrastructure Plugin
* Plugin URI: https://agenticlab.dev/lab
* Description: Modular infrastructure plugin with idempotent dbDelta migrations and PSR-4 architecture.
* Version: 2.1.0
* Requires at least: 6.7
* Requires PHP: 8.4
* Requires Plugins:
* Author: Artur Yusupov (Fullstack Agentic Lab)
* License: GPL-2.0-or-later
* Text Domain: agentic-lab-core
*/
declare(strict_types=1);
namespace AgenticLab\Plugin;
if (!defined('ABSPATH')) {
exit;
}
final class PluginBootstrap
{
public const VERSION = '2.1.0';
public const DB_VERSION_OPTION = 'agentic_lab_db_schema_version';
public const CRON_HOOK = 'agentic_lab_hourly_telemetry_cleanup';
public static function register(): void
{
// 1. Register PSR-4 class autoloader
spl_autoload_register(self::autoloadClass(...));
// 2. Lifecycle hooks for activation and deactivation
register_activation_hook(__FILE__, self::onActivate(...));
register_deactivation_hook(__FILE__, self::onDeactivate(...));
// 3. Automated schema upgrade on Git/CI deployment (where activation_hook does not fire)
add_action('plugins_loaded', self::maybeUpgradeDatabaseSchema(...), 5);
}
public static function autoloadClass(string $class): void
{
$prefix = 'AgenticLab\\Plugin\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = plugin_dir_path(__FILE__) . 'src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_readable($file)) {
require_once $file;
}
}
public static function onActivate(bool $networkWide = false): void
{
if (is_multisite() && $networkWide) {
/** @var list<int> $siteIds */
$siteIds = get_sites(['fields' => 'ids']);
foreach ($siteIds as $siteId) {
switch_to_blog((int) $siteId);
self::installSingleSite();
restore_current_blog();
}
return;
}
self::installSingleSite();
}
private static function installSingleSite(): void
{
self::runDbDeltaMigration();
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'hourly', self::CRON_HOOK);
}
flush_rewrite_rules(false);
}
public static function maybeUpgradeDatabaseSchema(): void
{
$installedVersion = (string) get_option(self::DB_VERSION_OPTION, '0.0.0');
if (version_compare($installedVersion, self::VERSION, '<')) {
self::runDbDeltaMigration();
}
}
/**
* Idempotent custom table migration adhering strictly to dbDelta() formatting rules.
*/
public static function runDbDeltaMigration(): void
{
global $wpdb;
$tableName = $wpdb->prefix . 'agentic_telemetry_logs';
$charsetCollate = $wpdb->get_charset_collate();
// CRITICAL: Exactly 2 spaces after PRIMARY KEY and no backticks for reliable dbDelta diffing!
$sql = "CREATE TABLE {$tableName} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
event_type varchar(64) NOT NULL,
payload longtext NOT NULL,
execution_ms float unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY event_created_idx (event_type, created_at)
) {$charsetCollate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
update_option(self::DB_VERSION_OPTION, self::VERSION, true);
}
/**
* Graceful deactivation: clear WP-Cron schedules without deleting user data or tables!
*/
public static function onDeactivate(): void
{
wp_clear_scheduled_hook(self::CRON_HOOK);
flush_rewrite_rules(false);
}
}
PluginBootstrap::register();
/*
* ============================================================================
* Isolated Uninstallation File: wp-content/plugins/agentic-lab-core/uninstall.php
* (Invoked automatically by core strictly upon complete plugin deletion)
* ============================================================================
*
* <?php
* declare(strict_types=1);
*
* if (!defined('WP_UNINSTALL_PLUGIN')) {
* exit;
* }
*
* global $wpdb;
* $tableName = $wpdb->prefix . 'agentic_telemetry_logs';
* $wpdb->query("DROP TABLE IF EXISTS {$tableName}");
*
* delete_option('agentic_lab_db_schema_version');
* wp_cache_flush();
*/The Mechanism: Invoking DROP TABLE or delete_option() inside the deactivation callback (register_deactivation_hook).
Why Developers Do It: Confusing "temporarily deactivating a plugin" with "completely deleting a plugin."
Production Consequence: An administrator deactivates the plugin for 30 seconds to diagnose a conflict or perform an upgrade and permanently loses their entire database of customer records, settings, and logs.
The Fix: Inside register_deactivation_hook, strictly clear background schedules (wp_clear_scheduled_hook) and transient caches. Purging tables and options belongs exclusively inside uninstall.php.
The Mechanism: Placing dbDelta() migrations exclusively within register_activation_hook(__FILE__, ...).
Why Developers Do It: Assuming that updating a plugin to a newer version automatically triggers the activation hook.
Production Consequence: During automated plugin updates via Git, Composer, WP-CLI, or the admin dashboard, WordPress NEVER invokes register_activation_hook. The updated PHP code queries new table columns that do not exist yet in MySQL, causing severe runtime SQL failures.
The Fix: Store the schema version in an option (agentic_lab_db_schema_version), verify it against code constants on plugins_loaded, and trigger dbDelta() automatically whenever code versions exceed database versions.
The Mechanism: Writing CREATE TABLE IF NOT EXISTS, omitting the double space in PRIMARY KEY (id), or using INDEX instead of KEY when passing queries to dbDelta().
Why Developers Do It: Standard MySQL syntax that executes cleanly in $wpdb->query() breaks the regular expressions utilized by dbDelta.
Production Consequence: The initial table may be created, but subsequent plugin updates with modified columns or new indexes fail to parse properly, causing dbDelta() to silently ignore schema updates.
The Fix: Strictly respect dbDelta rules: exactly two spaces in PRIMARY KEY (id), use KEY for index declarations, place each column on its own line, and omit IF NOT EXISTS.
The Mechanism: Writing DROP TABLE statements in uninstall.php without prefixing the script with if (!defined('WP_UNINSTALL_PLUGIN')) exit;.
Why Developers Do It: Overlooking the fact that uninstall.php resides in the publicly accessible /wp-content/plugins/my-plugin/uninstall.php directory.
Production Consequence: Although $wpdb is uninitialized without WordPress, direct HTTP GET requests from malicious scanners can trigger fatal errors revealing absolute server filesystem paths (Full Path Disclosure) or execute unauthenticated code if bootstrap files are loosely required.
The Fix: Always make the very first executable statement in uninstall.php: if (!defined('WP_UNINSTALL_PLUGIN')) { exit; }.
WordPress 7.0+ core parses Requires PHP: 8.4 and Requires Plugins before allowing activation in the UI.
Prevents fatal environment incompatibilities before running a single line of plugin code.
Create database tables via dbDelta(), schedule WP-Cron jobs, and perform soft rewrite flushes.
Multisite network activations iterate across all sub-sites using switch_to_blog().
Verify stored database schema version against code constants and run dbDelta() automatically.
Guarantees database integrity during Git and CI/CD automated deployments.
Clear periodic cron jobs via wp_clear_scheduled_hook() and flush transient rewrite rules.
All database tables and customer configurations remain completely untouched.
Verify WP_UNINSTALL_PLUGIN and execute DROP TABLE queries and option deletions.
Runs strictly when an administrator explicitly deletes an inactive plugin.