> wp-plugin-development

WordPress plugin development — architecture, hooks, Settings API, security, data, and lifecycle. Always-active rules when working on plugins or mu-plugins.

fetch
$curl "https://skillshub.wtf/alessioarzenton/claude-code-wp-toolkit/wp-plugin-development?format=md"
SKILL.mdwp-plugin-development

WordPress Plugin Development

Skill for creating and maintaining WordPress plugins. Covers architecture, lifecycle (activation/deactivation/uninstallation), Settings API, security, and data storage.

When to use

Apply this skill when the task involves:

  • Creating or restructuring a plugin (bootstrap, namespace, classes)
  • Adding hooks, actions, and filters
  • Managing activation, deactivation, and uninstallation
  • Creating settings pages with the Settings API
  • Implementing security (nonce, capability, sanitization, escaping)
  • Data storage (options, custom tables, migrations)

Prerequisites

Before starting, verify:

  • Plugin path (under plugins/ or mu-plugins/)
  • Whether the site is single or multisite
  • Minimum WordPress and PHP version of the project
  • Any dependencies (Composer, external libraries)

Procedure

1) Predictable architecture

Fundamental rules:

  • Single bootstrap: the main file with the plugin header is the only entry point
  • No side-effects on load: do not execute logic in the require — register everything on hooks
  • Dedicated loader: use a class or function to register all hooks in a centralized location
  • Separate admin code: protect admin code with is_admin() or specific hooks (admin_init, admin_menu) to reduce frontend overhead
<?php
/**
 * Plugin Name: {{PROJECT_NAME}} — Custom functionality
 * Description: Custom extensions for the project.
 * Version:     1.0.0
 * Author:      Developer
 * Text Domain: {{TEXT_DOMAIN}}
 * Requires PHP: 8.2
 */

declare(strict_types=1);

// Prevent direct access.
defined('ABSPATH') || exit;

// Load dependencies.
require_once __DIR__ . '/src/bootstrap.php';

Recommended structure:

my-plugin/
├── my-plugin.php           # Header + require bootstrap
├── src/
│   ├── bootstrap.php       # Register hooks and load classes
│   ├── Admin/              # Admin pages, settings
│   ├── Frontend/           # Shortcode, template tags
│   ├── REST/               # REST endpoints (if present)
│   └── Includes/           # Utilities, helpers
├── assets/                 # Compiled CSS/JS
├── languages/              # .pot/.po/.mo files
├── templates/              # PHP templates for output
└── uninstall.php           # Data cleanup on uninstallation

2) Lifecycle — Activation, deactivation, uninstallation

Lifecycle hooks are delicate — errors here block plugin activation.

Rules:

  • Register register_activation_hook and register_deactivation_hook in the main file, not inside other hooks
  • Call flush_rewrite_rules() only after registering CPTs/rewrite rules
  • Uninstallation must be explicit and safe
// In the main plugin file.
register_activation_hook(__FILE__, function (): void {
    // Create tables, initial options, roles.
    add_option('my_plugin_version', '1.0.0');

    // If you register CPTs, register them BEFORE flushing.
    register_custom_post_types();
    flush_rewrite_rules();
});

register_deactivation_hook(__FILE__, function (): void {
    // Remove cron, flush rules.
    wp_clear_scheduled_hook('my_plugin_cron');
    flush_rewrite_rules();
});

Uninstallation — prefer uninstall.php (safer than register_uninstall_hook):

// uninstall.php
defined('WP_UNINSTALL_PLUGIN') || exit;

delete_option('my_plugin_version');
delete_option('my_plugin_settings');
// Remove custom tables if present.

3) Settings API — Settings pages

Always use the native Settings API to manage options:

add_action('admin_init', function (): void {
    register_setting('my_plugin_options', 'my_plugin_settings', [
        'type'              => 'array',
        'sanitize_callback' => 'sanitize_plugin_settings',
        'default'           => [
            'api_url'   => '',
            'per_page'  => 10,
            'active'    => false,
        ],
    ]);

    add_settings_section(
        'my_plugin_general',
        __('General Settings', '{{TEXT_DOMAIN}}'),
        null,
        'my_plugin_options'
    );

    add_settings_field(
        'api_url',
        __('URL API', '{{TEXT_DOMAIN}}'),
        'render_api_url_field',
        'my_plugin_options',
        'my_plugin_general'
    );
});

function sanitize_plugin_settings(array $input): array {
    return [
        'api_url'   => esc_url_raw($input['api_url'] ?? ''),
        'per_page'  => absint($input['per_page'] ?? 10),
        'active'    => !empty($input['active']),
    ];
}
  • register_setting() with sanitize_callback for every option group
  • esc_url_raw() for URLs, absint() for integers, sanitize_text_field() for strings
  • Use get_option('my_plugin_settings') to read

4) Security baseline (always)

These rules apply to all plugin/theme code:

Validate input / Escape output

// INPUT — sanitize early.
$title  = sanitize_text_field(wp_unslash($_POST['title'] ?? ''));
$email  = sanitize_email($_POST['email'] ?? '');
$url    = esc_url_raw($_POST['url'] ?? '');
$html   = wp_kses_post($_POST['content'] ?? '');

// OUTPUT — escape late.
echo esc_html($title);
echo esc_attr($value);
echo esc_url($link);
echo wp_kses_post($html_content);

Nonce to prevent CSRF

// In the form.
wp_nonce_field('my_plugin_save', 'my_plugin_nonce');

// In the handler.
if (!wp_verify_nonce($_POST['my_plugin_nonce'] ?? '', 'my_plugin_save')) {
    wp_die(__('Security verification failed.', '{{TEXT_DOMAIN}}'));
}

Capability check for authorization

// ALWAYS check capabilities, not roles.
if (!current_user_can('manage_options')) {
    wp_die(__('You do not have permission for this action.', '{{TEXT_DOMAIN}}'));
}

Secure SQL

// NEVER concatenate strings in SQL queries.
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}my_table WHERE status = %s AND amount > %d",
        $status,
        $min_amount
    )
);

5) Data storage

Data typeWhere to storeWhen
Plugin configurationwp_options with get_option/update_optionFew values, frequent access
Per-post metadatapostmeta with get_post_meta/update_post_metaData tied to a single post
Structured/relational dataCustom table $wpdb->prefix . 'my_table'Many records, complex queries, performance
Temporary cacheTransients (get_transient/set_transient)API results, expensive calculations

Migrations and upgrades — always save a schema version:

function my_plugin_upgrade_check(): void {
    $current = get_option('my_plugin_db_version', '0');
    if (version_compare($current, '1.1.0', '<')) {
        my_plugin_upgrade_110();
        update_option('my_plugin_db_version', '1.1.0');
    }
}
add_action('plugins_loaded', 'my_plugin_upgrade_check');

Cron tasks — ensure idempotency:

// Register the event if it does not exist.
if (!wp_next_scheduled('my_plugin_cron')) {
    wp_schedule_event(time(), 'daily', 'my_plugin_cron');
}

add_action('my_plugin_cron', function (): void {
    // Idempotent logic — safe to run multiple times.
});

Verification checklist

  • The plugin activates without fatal errors or notices
  • Settings save and read correctly
  • Nonce and capability checks are present on every form/action
  • Uninstallation removes the plugin's data (and only that)
  • sanitize_callback defined for every register_setting
  • No direct access to $_POST/$_GET without sanitization
  • No SQL queries with string concatenation
  • The project lint/test passes (Pint, PHPUnit if present)

Common errors and solutions

ProblemLikely causeSolution
Activation hook not executedRegistered inside another hook, not in the main fileMove register_activation_hook to the main .php file
Settings not savedSetting not registered, wrong option group, nonce failedVerify register_setting(), check group and nonce
Security regressionNonce present but capability check missingAlways add both: nonce + current_user_can()
Custom table not createddbDelta() not called correctlyVerify SQL syntax and that dbDelta receives queries as a string
Plugin slow on frontendAdmin code loaded on every requestProtect with is_admin() or admin-specific hooks

What NOT to do

  • Do not execute logic at require/include — register everything on hooks
  • Do not use extract() — it makes code unreadable and insecure
  • Do not hardcode paths with ABSPATH . 'wp-content/' — use plugin_dir_path(), plugin_dir_url()
  • Do not create custom tables for data that would fit in wp_options or postmeta
  • Do not trust $_POST/$_GET/$_REQUEST without sanitization
  • Do not use wp_die() as the only error handling — return WP_Error when possible
  • Do not forget the text domain '{{TEXT_DOMAIN}}' in translatable strings

┌ stats

installs/wk0
░░░░░░░░░░
first seenMar 17, 2026
└────────────

┌ repo

alessioarzenton/claude-code-wp-toolkit
by alessioarzenton
└────────────

┌ tags

└────────────