Skip to content
Back to skills

Wp Plugin Development

ASecurity

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

  • 65 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added May 30, 2026
developmentrustphpsqlapifrontendsecurityperformance

Works with

  • api

Security analysis

A100/100

Scanned May 30, 2026

npx -y skills add ComeOnOliver/skillshub --skill wp-plugin-development --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Wp Plugin Development?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Wp Plugin Development
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/comeonoliver-wp-plugin-development/badge)](https://www.skillsdirectory.com/skills/comeonoliver-wp-plugin-development)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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
<?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

```php
// 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`):

```php
// 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:

```php
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

```php
// 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

```php
// 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

```php
// 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

```php
// 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:

```php
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:

```php
// 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

| 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

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.