> 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
> test-driven-development
Use when implementing any feature or bugfix, before writing implementation code
> systematic-debugging
Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
> seo
Optimize for search engine visibility and ranking. Use when asked to "improve SEO", "optimize for search", "fix meta tags", "add structured data", "sitemap optimization", or "search engine optimization".
> performance
Optimize web performance for faster loading and better user experience. Use when asked to "speed up my site", "optimize performance", "reduce load time", "fix slow loading", "improve page speed", or "performance audit".