| name | wp-plugin-development |
| description | WordPress plugin development — architecture, hooks, Settings API, security, data, and lifecycle. Always-active rules when working on plugins or mu-plugins. |
| user-invocable | false |
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
declare(strict_types=1);
defined('ABSPATH') || exit;
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
register_activation_hook(__FILE__, function (): void {
add_option('my_plugin_version', '1.0.0');
register_custom_post_types();
flush_rewrite_rules();
});
register_deactivation_hook(__FILE__, function (): void {
wp_clear_scheduled_hook('my_plugin_cron');
flush_rewrite_rules();
});
Uninstallation — prefer uninstall.php (safer than register_uninstall_hook):
defined('WP_UNINSTALL_PLUGIN') || exit;
delete_option('my_plugin_version');
delete_option('my_plugin_settings');
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
$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'] ?? '');
echo esc_html($title);
echo esc_attr($value);
echo esc_url($link);
echo wp_kses_post($html_content);
Nonce to prevent CSRF
wp_nonce_field('my_plugin_save', 'my_plugin_nonce');
if (!wp_verify_nonce($_POST['my_plugin_nonce'] ?? '', 'my_plugin_save')) {
wp_die(__('Security verification failed.', '{{TEXT_DOMAIN}}'));
}
Capability check for authorization
if (!current_user_can('manage_options')) {
wp_die(__('You do not have permission for this action.', '{{TEXT_DOMAIN}}'));
}
Secure SQL
$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:
if (!wp_next_scheduled('my_plugin_cron')) {
wp_schedule_event(time(), 'daily', 'my_plugin_cron');
}
add_action('my_plugin_cron', function (): void {
});
Verification checklist
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/' — 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