Skillshub wp-plugin-development

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

install
source · Clone the upstream repo
git clone https://github.com/ComeOnOliver/skillshub
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/alessioarzenton/claude-code-wp-toolkit/wp-plugin-development" ~/.claude/skills/comeonoliver-skillshub-wp-plugin-development-143f24 && rm -rf "$T"
manifest: skills/alessioarzenton/claude-code-wp-toolkit/wp-plugin-development/SKILL.md
source content

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 configuration
wp_options
with
get_option
/
update_option
Few values, frequent access
Per-post metadata
postmeta
with
get_post_meta
/
update_post_meta
Data 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 created
dbDelta()
not called correctly
Verify 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