wp-plugins
WordPress Plugin & Theme Development
Consolidated reference for plugin architecture, lifecycle, settings, data storage, WP-CLI integration, static analysis, coding standards, testing, and deployment.
Resource files (detailed code examples):
resources/settings-api.md— Full Settings_Page class implementationresources/wp-cli.md— WP-CLI custom commands and operations referenceresources/static-analysis-testing.md— PHPStan, PHPCS, and PHPUnit testingresources/build-deploy.md— Build, deploy, and release workflows
1. Plugin Architecture
Main Plugin File
Every plugin starts with a single PHP file containing the plugin header comment. WordPress reads this header to register the plugin in the admin UI.
<?php
/**
* Plugin Name: My Awesome Plugin
* Plugin URI: https://example.com/my-awesome-plugin
* Description: A short description of what this plugin does.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://example.com
* License: GPL-2.0-or-later
* Text Domain: my-awesome-plugin
* Domain Path: /languages
* Requires at least: 6.2
* Requires PHP: 7.4
*/
// Prevent direct access.
defined( 'ABSPATH' ) || exit;
// Define constants.
define( 'MAP_VERSION', '1.0.0' );
define( 'MAP_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MAP_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MAP_PLUGIN_FILE', __FILE__ );
// Autoloader (Composer PSR-4).
if ( file_exists( MAP_PLUGIN_DIR . 'vendor/autoload.php' ) ) {
require_once MAP_PLUGIN_DIR . 'vendor/autoload.php';
}
// Lifecycle hooks — must be registered at top level, not inside other hooks.
register_activation_hook( __FILE__, [ 'MyAwesomePlugin\\Activator', 'activate' ] );
register_deactivation_hook( __FILE__, [ 'MyAwesomePlugin\\Deactivator', 'deactivate' ] );
// Bootstrap the plugin on `plugins_loaded`.
add_action( 'plugins_loaded', function () {
MyAwesomePlugin\Plugin::instance()->init();
} );
Bootstrap Pattern
- The main file loads, defines constants, requires the autoloader, registers
lifecycle hooks, and defers initialization to a
plugins_loadedcallback. - Avoid heavy side effects at file load time. Load on hooks.
- Keep admin-only code behind
is_admin()checks or admin-specific hooks.
Namespaces and Autoloading (PSR-4)
Configure in composer.json:
{
"autoload": {
"psr-4": {
"MyAwesomePlugin\\": "includes/"
}
},
"autoload-dev": {
"psr-4": {
"MyAwesomePlugin\\Tests\\": "tests/"
}
}
}
Run composer dump-autoload after changes.
Folder Structure
my-awesome-plugin/
my-awesome-plugin.php # Main plugin file with header
uninstall.php # Cleanup on uninstall
composer.json / phpstan.neon / phpcs.xml / .distignore
includes/ # Core PHP classes (PSR-4 root)
Plugin.php / Activator.php / Deactivator.php
Admin/ Frontend/ CLI/ REST/
admin/ public/ # View partials
assets/ templates/ languages/ tests/
Singleton vs Dependency Injection
Use singleton for the root plugin class only. Prefer dependency injection for everything else (testability).
namespace MyAwesomePlugin;
class Plugin {
private static ?Plugin $instance = null;
public static function instance(): Plugin {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
public function init(): void {
( new Admin\Settings_Page() )->register();
( new Frontend\Assets() )->register();
if ( defined( 'WP_CLI' ) && WP_CLI ) {
CLI\Commands::register();
}
}
}
Action / Filter Hook Architecture
- Actions execute code at a specific point (
do_action/add_action). - Filters modify data and return it (
apply_filters/add_filter). - Always specify the priority (default 10) and accepted args count.
- Provide your own hooks so other developers can extend your plugin.
// Registering hooks in your plugin.
add_action( 'init', [ $this, 'register_post_types' ] );
add_filter( 'the_content', [ $this, 'append_cta' ], 20 );
// Providing extensibility.
$output = apply_filters( 'map_formatted_price', $formatted, $raw_price );
do_action( 'map_after_order_processed', $order_id );
2. Lifecycle Hooks
Lifecycle hooks must be registered at top-level scope in the main plugin file, not inside other hooks or conditional blocks.
Activation
namespace MyAwesomePlugin;
class Activator {
public static function activate(): void {
self::create_tables();
if ( false === get_option( 'map_settings' ) ) {
update_option( 'map_settings', [
'enabled' => true, 'api_key' => '', 'max_results' => 10,
], false );
}
update_option( 'map_db_version', MAP_VERSION, false );
// Register CPTs first, then flush so rules exist.
( new Plugin() )->register_post_types();
flush_rewrite_rules();
}
private static function create_tables(): void {
global $wpdb;
$table = $wpdb->prefix . 'map_logs';
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
action varchar(100) NOT NULL DEFAULT '',
data longtext NOT NULL DEFAULT '',
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id)
) {$wpdb->get_charset_collate()};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
}
Deactivation
namespace MyAwesomePlugin;
class Deactivator {
public static function deactivate(): void {
// Clear scheduled cron events.
wp_clear_scheduled_hook( 'map_daily_cleanup' );
wp_clear_scheduled_hook( 'map_hourly_sync' );
// Flush rewrite rules to remove custom rewrites.
flush_rewrite_rules();
}
}
Uninstall
Create uninstall.php in the plugin root (preferred over register_uninstall_hook):
<?php
// uninstall.php — runs when plugin is deleted via admin UI.
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
global $wpdb;
delete_option( 'map_settings' );
delete_option( 'map_db_version' );
$wpdb->query( "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE 'map\_%'" );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}map_logs" );
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_map_%'
OR option_name LIKE '_transient_timeout_map_%'"
);
$wpdb->query( "DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE 'map\_%'" );
Key rule: Never run expensive operations on every request. Activation, deactivation, and uninstall hooks exist precisely so you can perform setup and teardown only when needed.
3. Settings API
Full Settings_Page class implementation: see resources/settings-api.md.
Options API
$settings = get_option( 'map_settings', [] ); // Read.
update_option( 'map_settings', $new_values ); // Write.
delete_option( 'map_settings' ); // Delete.
update_option( 'map_large_cache', $data, false ); // autoload=false for infrequent options.
4. Data Storage
When to Use What
| Storage | Use Case | Size Guidance |
|---|---|---|
| Options API | Small config, plugin settings | < 1 MB per option |
| Post meta | Per-post data tied to a specific post | Keyed per post |
| User meta | Per-user preferences or state | Keyed per user |
| Custom tables | Structured, queryable, or large datasets | No practical limit |
| Transients | Cached data with expiration | Temporary, auto-expires |
Custom Tables with dbDelta
dbDelta() rules: each field on its own line, two spaces between name and type,
PRIMARY KEY with two spaces before, use KEY not INDEX.
function map_create_table(): void {
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}map_events (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
name varchar(255) NOT NULL DEFAULT '',
status varchar(20) NOT NULL DEFAULT 'draft',
event_date datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY status (status)
) {$wpdb->get_charset_collate()};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
Schema Upgrades
Store a version and compare on plugins_loaded:
add_action( 'plugins_loaded', function () {
if ( version_compare( get_option( 'map_db_version', '0' ), MAP_VERSION, '<' ) ) {
map_create_table(); // dbDelta handles ALTER for existing tables.
update_option( 'map_db_version', MAP_VERSION, false );
}
} );
Transients
$data = get_transient( 'map_api_results' );
if ( false === $data ) {
$data = map_fetch_from_api();
set_transient( 'map_api_results', $data, HOUR_IN_SECONDS );
}
Safe SQL
Always use $wpdb->prepare() for user input. Never concatenate variables into SQL.
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}map_events WHERE status = %s AND event_date > %s",
$status, $date
) );
5. WP-CLI, Static Analysis, Testing, Build & Deploy
Detailed references in resource files:
- WP-CLI (custom commands, operations reference):
resources/wp-cli.md - PHPStan, PHPCS, Testing:
resources/static-analysis-testing.md - Build & Deploy (Composer, JS/CSS, SVN, GitHub releases):
resources/build-deploy.md
6. Security Checklist
- Sanitize on input, escape on output. Never trust
$_POST/$_GETdirectly. - Use
wp_unslash()before sanitizing superglobals. Read explicit keys only. - Pair nonce checks with
current_user_can(). Nonces prevent CSRF, not authz. - Use
$wpdb->prepare()for all SQL with user input. - Escape output:
esc_html(),esc_attr(),esc_url(),wp_kses_post().
7. WordPress 6.7-6.9 Breaking Changes
WP 6.7 -- Translation Loading
load_plugin_textdomain() is auto-called for plugins with a Text Domain header.
If you call it manually from init, it may load before the preferred translation
source (language packs). Move manual calls to after_setup_theme or remove them
entirely if the header is set.
WP 6.8 -- Bcrypt Password Hashing
WordPress 6.8 migrates from phpass MD5 to bcrypt (password_hash()). Existing
hashes upgrade transparently on next login. Impact on plugins:
wp_check_password()/wp_hash_password()still work -- use these, never hash passwords manually.- Plugins that store or compare raw
$hashstrings from theuser_passcolumn will break if they assume$P$prefix. - Custom authentication that bypasses
wp_check_password()must handle both$P$(legacy) and$2y$(bcrypt) prefixes.
WP 6.9 -- Abilities API & WP_Dependencies Deprecation
WP_Dependenciesclass is deprecated. Usewp_enqueue_script()/wp_enqueue_style()-- never instantiateWP_Dependenciesdirectly.- Abilities API (
register_ability()) replaces ad-hoccurrent_user_can()for REST route permissions (see wp-rest-api skill).
8. Common Mistakes
| Mistake | Why It Fails | Fix |
|---|---|---|
Lifecycle hooks inside add_action('init', ...) |
Activation/deactivation hooks not detected | Register at top-level scope in main file |
flush_rewrite_rules() without registering CPTs first |
Rules flushed before custom post types exist | Call CPT registration, then flush |
Missing sanitize_callback on register_setting() |
Unsanitized data saved to database | Always provide sanitize callback |
autoload left as yes for large/infrequent options |
Every page load fetches unused data | Pass false as 4th arg to update_option() |
Using $wpdb->query() with string concatenation |
SQL injection vulnerability | Use $wpdb->prepare() |
| Nonce check without capability check | CSRF prevented but no authorization | Always pair nonce + current_user_can() |
innerHTML = in admin JS |
XSS vector | Use textContent or DOM creation methods |
No defined('ABSPATH') guard |
Direct file access possible | Add defined('ABSPATH') || exit; to every PHP file |
Running dbDelta() on every request |
Slow table introspection on every page load | Run only on activation or version upgrade |
Not checking WP_UNINSTALL_PLUGIN in uninstall.php |
File could be loaded outside uninstall context | Check constant before running cleanup |
| PHPStan baseline grows unchecked | New errors silently added to baseline | Review baseline changes in PRs; never baseline new code |
Missing --dry-run on wp search-replace |
Irreversible changes to production database | Always dry-run first, then backup, then run |
Forgetting --url= in multisite WP-CLI |
Command hits wrong site | Always include --url= for site-specific operations |