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.mdsource 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
orplugins/
)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
— register everything on hooksrequire - Dedicated loader: use a class or function to register all hooks in a centralized location
- Separate admin code: protect admin code with
or specific hooks (is_admin()
,admin_init
) to reduce frontend overheadadmin_menu
<?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
andregister_activation_hook
in the main file, not inside other hooksregister_deactivation_hook - Call
only after registering CPTs/rewrite rulesflush_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']), ]; }
withregister_setting()
for every option groupsanitize_callback
for URLs,esc_url_raw()
for integers,absint()
for stringssanitize_text_field()- Use
to readget_option('my_plugin_settings')
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 type | Where to store | When |
|---|---|---|
| Plugin configuration | with / | Few values, frequent access |
| Per-post metadata | with / | Data tied to a single post |
| Structured/relational data | Custom table | Many records, complex queries, performance |
| Temporary cache | Transients (/) | 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)
-
defined for everysanitize_callbackregister_setting - No direct access to
/$_POST
without sanitization$_GET - No SQL queries with string concatenation
- The project lint/test passes (Pint, PHPUnit if present)
Common errors and solutions
| Problem | Likely cause | Solution |
|---|---|---|
| Activation hook not executed | Registered inside another hook, not in the main file | Move to the main .php file |
| Settings not saved | Setting not registered, wrong option group, nonce failed | Verify , check group and nonce |
| Security regression | Nonce present but capability check missing | Always add both: nonce + |
| Custom table not created | not called correctly | Verify SQL syntax and that receives queries as a string |
| Plugin slow on frontend | Admin code loaded on every request | Protect with or admin-specific hooks |
What NOT to do
- Do not execute logic at
/require
— register everything on hooksinclude - Do not use
— it makes code unreadable and insecureextract() - Do not hardcode paths with
— useABSPATH . 'wp-content/'
,plugin_dir_path()plugin_dir_url() - Do not create custom tables for data that would fit in
orwp_optionspostmeta - Do not trust
/$_POST
/$_GET
without sanitization$_REQUEST - Do not use
as the only error handling — returnwp_die()
when possibleWP_Error - Do not forget the text domain
in translatable strings'{{TEXT_DOMAIN}}'