> wp-plugin-development
WordPress plugin development — architecture, hooks, Settings API, security, data, and lifecycle. Always-active rules when working on plugins or mu-plugins.
curl "https://skillshub.wtf/alessioarzenton/claude-code-wp-toolkit/wp-plugin-development?format=md"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/ormu-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_hookandregister_deactivation_hookin 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()withsanitize_callbackfor every option groupesc_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 type | Where to store | When |
|---|---|---|
| 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 data | Custom table $wpdb->prefix . 'my_table' | Many records, complex queries, performance |
| Temporary cache | Transients (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_callbackdefined for everyregister_setting - No direct access to
$_POST/$_GETwithout sanitization - 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 register_activation_hook to the main .php file |
| Settings not saved | Setting not registered, wrong option group, nonce failed | Verify register_setting(), check group and nonce |
| Security regression | Nonce present but capability check missing | Always 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 frontend | Admin code loaded on every request | Protect 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/'— useplugin_dir_path(),plugin_dir_url() - Do not create custom tables for data that would fit in
wp_optionsorpostmeta - Do not trust
$_POST/$_GET/$_REQUESTwithout sanitization - Do not use
wp_die()as the only error handling — returnWP_Errorwhen possible - Do not forget the text domain
'{{TEXT_DOMAIN}}'in translatable strings
> related_skills --same-repo
> wp-sage
Conventions for WordPress Bedrock + Sage + Acorn + Blade + Tailwind CSS 4 + Vite projects. Always-active rules.
> wp-classic
Conventions for WordPress Bedrock projects with classic PHP theme (no Sage/Acorn/Blade). Always-active rules.
> wp-rest-api
WordPress REST endpoint development and debugging. Always-active rules when working with REST routes, API authentication, or data exposure via JSON.
> wp-performance-backend
WordPress backend performance optimization — profiling, queries, object cache, autoload, cron, and remote HTTP. Always-active rules when investigating slowness issues.