Files
2025-11-30 08:25:50 +08:00

40 KiB

name, description, license
name description license
wordpress-plugin-core Build secure WordPress plugins with core patterns for hooks, database interactions, Settings API, custom post types, REST API, and AJAX. Covers three architecture patterns (Simple, OOP, PSR-4) and the Security Trinity. Use when creating plugins, implementing nonces/sanitization/escaping, working with $wpdb prepared statements, or troubleshooting SQL injection, XSS, CSRF vulnerabilities, or plugin activation errors. MIT

WordPress Plugin Development (Core)

Status: Production Ready Last Updated: 2025-11-06 Dependencies: None (WordPress 5.9+, PHP 7.4+) Latest Versions: WordPress 6.7+, PHP 8.0+ recommended


Quick Start (10 Minutes)

1. Choose Your Plugin Structure

WordPress plugins can use three architecture patterns:

  • Simple (functions only) - For small plugins with <5 functions
  • OOP (Object-Oriented) - For medium plugins with related functionality
  • PSR-4 (Namespaced + Composer autoload) - For large/modern plugins

Why this matters:

  • Simple plugins are easiest to start but don't scale well
  • OOP provides organization without modern PHP features
  • PSR-4 is the modern standard (2025) and most maintainable

2. Create Plugin Header

Every plugin MUST have a header comment in the main file:

<?php
/**
 * Plugin Name:       My Awesome Plugin
 * Plugin URI:        https://example.com/my-plugin/
 * Description:       Brief description of what this plugin does.
 * Version:           1.0.0
 * Requires at least: 5.9
 * Requires PHP:      7.4
 * Author:            Your Name
 * Author URI:        https://yoursite.com/
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-plugin
 * Domain Path:       /languages
 */

// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

CRITICAL:

  • Plugin Name is the ONLY required field
  • Text Domain must match plugin slug exactly (for translations)
  • Always add ABSPATH check to prevent direct file access

3. Implement The Security Foundation

Before writing ANY functionality, implement these 5 security essentials:

// 1. Unique Prefix (4-5 chars minimum)
define( 'MYPL_VERSION', '1.0.0' );

function mypl_init() {
    // Your code
}
add_action( 'init', 'mypl_init' );

// 2. ABSPATH Check (every PHP file)
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// 3. Nonces for Forms
<input type="hidden" name="mypl_nonce" value="<?php echo wp_create_nonce( 'mypl_action' ); ?>" />

// 4. Sanitize Input, Escape Output
$clean = sanitize_text_field( $_POST['input'] );
echo esc_html( $output );

// 5. Prepared Statements for Database
global $wpdb;
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}table WHERE id = %d",
        $id
    )
);

The 5-Step Security Foundation

WordPress plugin security has THREE components that must ALL be present:

Step 1: Use Unique Prefix for Everything

Why: Prevents naming conflicts with other plugins and WordPress core.

Rules:

  • 4-5 characters minimum
  • Apply to: functions, classes, constants, options, transients, meta keys, global variables
  • Avoid: wp_, __, _, "WordPress"
// GOOD
function mypl_function_name() {}
class MyPL_Class_Name {}
define( 'MYPL_CONSTANT', 'value' );
add_option( 'mypl_option', 'value' );
set_transient( 'mypl_cache', $data, HOUR_IN_SECONDS );

// BAD
function function_name() {}  // No prefix, will conflict
class Settings {}  // Too generic

Step 2: Check Capabilities, Not Just Admin Status

ERROR: Using is_admin() for permission checks

// WRONG - Anyone can access admin area URLs
if ( is_admin() ) {
    // Delete user data - SECURITY HOLE
}

// CORRECT - Check user capability
if ( current_user_can( 'manage_options' ) ) {
    // Delete user data - Now secure
}

Common Capabilities:

  • manage_options - Administrator
  • edit_posts - Editor/Author
  • publish_posts - Author
  • edit_pages - Editor
  • read - Subscriber

Step 3: The Security Trinity

Input → Processing → Output each require different functions:

// SANITIZATION (Input) - Clean user data
$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );
$url = esc_url_raw( $_POST['url'] );
$html = wp_kses_post( $_POST['content'] );  // Allow safe HTML
$key = sanitize_key( $_POST['option'] );
$ids = array_map( 'absint', $_POST['ids'] );  // Array of integers

// VALIDATION (Logic) - Verify it meets requirements
if ( ! is_email( $email ) ) {
    wp_die( 'Invalid email' );
}

// ESCAPING (Output) - Make safe for display
echo esc_html( $name );
echo '<a href="' . esc_url( $url ) . '">';
echo '<div class="' . esc_attr( $class ) . '">';
echo '<textarea>' . esc_textarea( $content ) . '</textarea>';

Critical Rule: Sanitize on INPUT, escape on OUTPUT. Never trust user data.

Step 4: Nonces (CSRF Protection)

What: One-time tokens that prove requests came from your site.

Form Pattern:

// Generate nonce in form
<form method="post">
    <?php wp_nonce_field( 'mypl_action', 'mypl_nonce' ); ?>
    <input type="text" name="data" />
    <button type="submit">Submit</button>
</form>

// Verify nonce in handler
if ( ! isset( $_POST['mypl_nonce'] ) || ! wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' ) ) {
    wp_die( 'Security check failed' );
}

// Now safe to proceed
$data = sanitize_text_field( $_POST['data'] );

AJAX Pattern:

// JavaScript
jQuery.ajax({
    url: ajaxurl,
    data: {
        action: 'mypl_ajax_action',
        nonce: mypl_ajax_object.nonce,
        data: formData
    }
});
// PHP Handler
function mypl_ajax_handler() {
    check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );

    // Safe to proceed
    wp_send_json_success( array( 'message' => 'Success' ) );
}
add_action( 'wp_ajax_mypl_ajax_action', 'mypl_ajax_handler' );

// Localize script with nonce
wp_localize_script( 'mypl-script', 'mypl_ajax_object', array(
    'ajaxurl' => admin_url( 'admin-ajax.php' ),
    'nonce'   => wp_create_nonce( 'mypl-ajax-nonce' ),
) );

Step 5: Prepared Statements for Database

CRITICAL: Always use $wpdb->prepare() for queries with user input.

global $wpdb;

// WRONG - SQL Injection vulnerability
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}table WHERE id = {$_GET['id']}" );

// CORRECT - Prepared statement
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}table WHERE id = %d",
        $_GET['id']
    )
);

Placeholders:

  • %s - String
  • %d - Integer
  • %f - Float

LIKE Queries (Special Case):

$search = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}posts WHERE post_title LIKE %s",
        $search
    )
);

Critical Rules

Always Do

Use unique prefix (4-5 chars) for all global code (functions, classes, options, transients) Add ABSPATH check to every PHP file: if ( ! defined( 'ABSPATH' ) ) exit; Check capabilities (current_user_can()) not just is_admin() Verify nonces for all forms and AJAX requests Use $wpdb->prepare() for all database queries with user input Sanitize input with sanitize_*() functions before saving Escape output with esc_*() functions before displaying Flush rewrite rules on activation when registering custom post types Use uninstall.php for permanent cleanup (not deactivation hook) Follow WordPress Coding Standards (tabs for indentation, Yoda conditions)

Never Do

Never use extract() - Creates security vulnerabilities Never trust $_POST/$_GET without sanitization Never concatenate user input into SQL - Always use prepare() Never use is_admin() alone for permission checks Never output unsanitized data - Always escape Never use generic function/class names - Always prefix Never use short PHP tags <? or <?= - Use <?php only Never delete user data on deactivation - Only on uninstall Never register uninstall hook repeatedly - Only once on activation Never use register_uninstall_hook() in main flow - Use uninstall.php instead


Known Issues Prevention

This skill prevents 20 documented issues:

Issue #1: SQL Injection

Error: Database compromised via unescaped user input Source: https://patchstack.com/articles/sql-injection/ (15% of all vulnerabilities) Why It Happens: Direct concatenation of user input into SQL queries Prevention: Always use $wpdb->prepare() with placeholders

// VULNERABLE
$wpdb->query( "DELETE FROM {$wpdb->prefix}table WHERE id = {$_GET['id']}" );

// SECURE
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );

Issue #2: XSS (Cross-Site Scripting)

Error: Malicious JavaScript executed in user browsers Source: https://patchstack.com (35% of all vulnerabilities) Why It Happens: Outputting unsanitized user data to HTML Prevention: Always escape output with context-appropriate function

// VULNERABLE
echo $_POST['name'];
echo '<div class="' . $_POST['class'] . '">';

// SECURE
echo esc_html( $_POST['name'] );
echo '<div class="' . esc_attr( $_POST['class'] ) . '">';

Issue #3: CSRF (Cross-Site Request Forgery)

Error: Unauthorized actions performed on behalf of users Source: https://blog.nintechnet.com/25-wordpress-plugins-vulnerable-to-csrf-attacks/ Why It Happens: No verification that requests originated from your site Prevention: Use nonces with wp_nonce_field() and wp_verify_nonce()

// VULNERABLE
if ( $_POST['action'] == 'delete' ) {
    delete_user( $_POST['user_id'] );
}

// SECURE
if ( ! wp_verify_nonce( $_POST['nonce'], 'mypl_delete_user' ) ) {
    wp_die( 'Security check failed' );
}
delete_user( absint( $_POST['user_id'] ) );

Issue #4: Missing Capability Checks

Error: Regular users can access admin functions Source: WordPress Security Review Guidelines Why It Happens: Using is_admin() instead of current_user_can() Prevention: Always check capabilities, not just admin context

// VULNERABLE
if ( is_admin() ) {
    // Any logged-in user can trigger this
}

// SECURE
if ( current_user_can( 'manage_options' ) ) {
    // Only administrators can trigger this
}

Issue #5: Direct File Access

Error: PHP files executed outside WordPress context Source: WordPress Plugin Handbook Why It Happens: No ABSPATH check at top of file Prevention: Add ABSPATH check to every PHP file

// Add to top of EVERY PHP file
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

Issue #6: Prefix Collision

Error: Functions/classes conflict with other plugins Source: WordPress Coding Standards Why It Happens: Generic names without unique prefix Prevention: Use 4-5 character prefix on ALL global code

// CAUSES CONFLICTS
function init() {}
class Settings {}
add_option( 'api_key', $value );

// SAFE
function mypl_init() {}
class MyPL_Settings {}
add_option( 'mypl_api_key', $value );

Issue #7: Rewrite Rules Not Flushed

Error: Custom post types return 404 errors Source: WordPress Plugin Handbook Why It Happens: Forgot to flush rewrite rules after registering CPT Prevention: Flush on activation, clear on deactivation

function mypl_activate() {
    mypl_register_cpt();
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );

function mypl_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );

Issue #8: Transients Not Cleaned

Error: Database accumulates expired transients Source: WordPress Transients API Documentation Why It Happens: No cleanup on uninstall Prevention: Delete transients in uninstall.php

// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
    exit;
}

global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mypl_%'" );
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_timeout_mypl_%'" );

Issue #9: Scripts Loaded Everywhere

Error: Performance degraded by unnecessary asset loading Source: WordPress Performance Best Practices Why It Happens: Enqueuing scripts/styles without conditional checks Prevention: Only load assets where needed

// BAD - Loads on every page
add_action( 'wp_enqueue_scripts', function() {
    wp_enqueue_script( 'mypl-script', $url );
} );

// GOOD - Only loads on specific page
add_action( 'wp_enqueue_scripts', function() {
    if ( is_page( 'my-page' ) ) {
        wp_enqueue_script( 'mypl-script', $url, array( 'jquery' ), '1.0', true );
    }
} );

Issue #10: Missing Sanitization on Save

Error: Malicious data stored in database Source: WordPress Data Validation Why It Happens: Saving $_POST data without sanitization Prevention: Always sanitize before saving

// VULNERABLE
update_option( 'mypl_setting', $_POST['value'] );

// SECURE
update_option( 'mypl_setting', sanitize_text_field( $_POST['value'] ) );

Issue #11: Incorrect LIKE Queries

Error: SQL syntax errors or injection vulnerabilities Source: WordPress $wpdb Documentation Why It Happens: LIKE wildcards not escaped properly Prevention: Use $wpdb->esc_like()

// WRONG
$search = '%' . $term . '%';

// CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );

Issue #12: Using extract()

Error: Variable collision and security vulnerabilities Source: WordPress Coding Standards Why It Happens: extract() creates variables from array keys Prevention: Never use extract(), access array elements directly

// DANGEROUS
extract( $_POST );
// Now $any_array_key becomes a variable

// SAFE
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';

Issue #13: Missing Permission Callback in REST API

Error: Endpoints accessible to everyone Source: WordPress REST API Handbook Why It Happens: No permission_callback specified Prevention: Always add permission_callback

// VULNERABLE
register_rest_route( 'myplugin/v1', '/data', array(
    'callback' => 'my_callback',
) );

// SECURE
register_rest_route( 'myplugin/v1', '/data', array(
    'callback'            => 'my_callback',
    'permission_callback' => function() {
        return current_user_can( 'edit_posts' );
    },
) );

Issue #14: Uninstall Hook Registered Repeatedly

Error: Option written on every page load Source: WordPress Plugin Handbook Why It Happens: register_uninstall_hook() called in main flow Prevention: Use uninstall.php file instead

// BAD - Runs on every page load
register_uninstall_hook( __FILE__, 'mypl_uninstall' );

// GOOD - Use uninstall.php file (preferred method)
// Create uninstall.php in plugin root

Issue #15: Data Deleted on Deactivation

Error: Users lose data when temporarily disabling plugin Source: WordPress Plugin Development Best Practices Why It Happens: Confusion about deactivation vs uninstall Prevention: Only delete data in uninstall.php, never on deactivation

// WRONG - Deletes user data on deactivation
register_deactivation_hook( __FILE__, function() {
    delete_option( 'mypl_user_settings' );
} );

// CORRECT - Only clear temporary data on deactivation
register_deactivation_hook( __FILE__, function() {
    delete_transient( 'mypl_cache' );
} );

// CORRECT - Delete all data in uninstall.php

Issue #16: Using Deprecated Functions

Error: Plugin breaks on WordPress updates Source: WordPress Deprecated Functions List Why It Happens: Using functions removed in newer WordPress versions Prevention: Enable WP_DEBUG during development

// In wp-config.php (development only)
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

Issue #17: Text Domain Mismatch

Error: Translations don't load Source: WordPress Internationalization Why It Happens: Text domain doesn't match plugin slug Prevention: Use exact plugin slug everywhere

// Plugin header
// Text Domain: my-plugin

// In code - MUST MATCH EXACTLY
__( 'Text', 'my-plugin' );
_e( 'Text', 'my-plugin' );

Issue #18: Missing Plugin Dependencies

Error: Fatal error when required plugin is inactive Source: WordPress Plugin Dependencies Why It Happens: No check for required plugins Prevention: Check for dependencies on plugins_loaded

add_action( 'plugins_loaded', function() {
    if ( ! class_exists( 'WooCommerce' ) ) {
        add_action( 'admin_notices', function() {
            echo '<div class="error"><p>My Plugin requires WooCommerce.</p></div>';
        } );
        return;
    }
    // Initialize plugin
} );

Issue #19: Autosave Triggering Meta Save

Error: Meta saved multiple times, performance issues Source: WordPress Post Meta Why It Happens: No autosave check in save_post hook Prevention: Check for DOING_AUTOSAVE constant

add_action( 'save_post', function( $post_id ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    // Safe to save meta
} );

Issue #20: admin-ajax.php Performance

Error: Slow AJAX responses Source: https://deliciousbrains.com/comparing-wordpress-rest-api-performance-admin-ajax-php/ Why It Happens: admin-ajax.php loads entire WordPress core Prevention: Use REST API for new projects (10x faster)

// OLD: admin-ajax.php (still works but slower)
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );

// NEW: REST API (10x faster, recommended)
add_action( 'rest_api_init', function() {
    register_rest_route( 'myplugin/v1', '/endpoint', array(
        'methods'             => 'POST',
        'callback'            => 'mypl_rest_handler',
        'permission_callback' => function() {
            return current_user_can( 'edit_posts' );
        },
    ) );
} );

Plugin Architecture Patterns

Pattern 1: Simple Plugin (Functions Only)

When to use: Small plugins with <5 functions, no complex state

<?php
/**
 * Plugin Name: Simple Plugin
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

function mypl_init() {
    // Your code here
}
add_action( 'init', 'mypl_init' );

function mypl_admin_menu() {
    add_options_page(
        'My Plugin',
        'My Plugin',
        'manage_options',
        'my-plugin',
        'mypl_settings_page'
    );
}
add_action( 'admin_menu', 'mypl_admin_menu' );

function mypl_settings_page() {
    ?>
    <div class="wrap">
        <h1>My Plugin Settings</h1>
    </div>
    <?php
}

Pattern 2: OOP Plugin

When to use: Medium plugins with related functionality, need organization

<?php
/**
 * Plugin Name: OOP Plugin
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class MyPL_Plugin {

    private static $instance = null;

    public static function get_instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->define_constants();
        $this->init_hooks();
    }

    private function define_constants() {
        define( 'MYPL_VERSION', '1.0.0' );
        define( 'MYPL_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
        define( 'MYPL_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
    }

    private function init_hooks() {
        add_action( 'init', array( $this, 'init' ) );
        add_action( 'admin_menu', array( $this, 'admin_menu' ) );
    }

    public function init() {
        // Initialization code
    }

    public function admin_menu() {
        add_options_page(
            'My Plugin',
            'My Plugin',
            'manage_options',
            'my-plugin',
            array( $this, 'settings_page' )
        );
    }

    public function settings_page() {
        ?>
        <div class="wrap">
            <h1>My Plugin Settings</h1>
        </div>
        <?php
    }
}

// Initialize plugin
function mypl() {
    return MyPL_Plugin::get_instance();
}
mypl();

When to use: Large/modern plugins, team development, 2025+ best practice

Directory Structure:

my-plugin/
├── my-plugin.php       # Main file
├── composer.json       # Autoloading config
├── src/                # PSR-4 autoloaded classes
│   ├── Admin.php
│   ├── Frontend.php
│   └── Settings.php
├── languages/
└── uninstall.php

composer.json:

{
    "name": "my-vendor/my-plugin",
    "autoload": {
        "psr-4": {
            "MyPlugin\\": "src/"
        }
    },
    "require": {
        "php": ">=7.4"
    }
}

my-plugin.php:

<?php
/**
 * Plugin Name: PSR-4 Plugin
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';

use MyPlugin\Admin;
use MyPlugin\Frontend;

class MyPlugin {

    private static $instance = null;

    public static function get_instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->init();
    }

    private function init() {
        new Admin();
        new Frontend();
    }
}

MyPlugin::get_instance();

src/Admin.php:

<?php

namespace MyPlugin;

class Admin {

    public function __construct() {
        add_action( 'admin_menu', array( $this, 'add_menu' ) );
    }

    public function add_menu() {
        add_options_page(
            'My Plugin',
            'My Plugin',
            'manage_options',
            'my-plugin',
            array( $this, 'settings_page' )
        );
    }

    public function settings_page() {
        ?>
        <div class="wrap">
            <h1>My Plugin Settings</h1>
        </div>
        <?php
    }
}

Common Patterns

Pattern 1: Custom Post Types

function mypl_register_cpt() {
    register_post_type( 'book', array(
        'labels' => array(
            'name'          => 'Books',
            'singular_name' => 'Book',
            'add_new_item'  => 'Add New Book',
        ),
        'public'       => true,
        'has_archive'  => true,
        'show_in_rest' => true,  // Gutenberg support
        'supports'     => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
        'rewrite'      => array( 'slug' => 'books' ),
        'menu_icon'    => 'dashicons-book',
    ) );
}
add_action( 'init', 'mypl_register_cpt' );

// CRITICAL: Flush rewrite rules on activation
function mypl_activate() {
    mypl_register_cpt();
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );

function mypl_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );

Pattern 2: Custom Taxonomies

function mypl_register_taxonomy() {
    register_taxonomy( 'genre', 'book', array(
        'labels' => array(
            'name'          => 'Genres',
            'singular_name' => 'Genre',
        ),
        'hierarchical' => true,  // Like categories
        'show_in_rest' => true,
        'rewrite'      => array( 'slug' => 'genre' ),
    ) );
}
add_action( 'init', 'mypl_register_taxonomy' );

Pattern 3: Meta Boxes

function mypl_add_meta_box() {
    add_meta_box(
        'book_details',
        'Book Details',
        'mypl_meta_box_html',
        'book',
        'normal',
        'high'
    );
}
add_action( 'add_meta_boxes', 'mypl_add_meta_box' );

function mypl_meta_box_html( $post ) {
    $isbn = get_post_meta( $post->ID, '_book_isbn', true );

    wp_nonce_field( 'mypl_save_meta', 'mypl_meta_nonce' );
    ?>
    <label for="book_isbn">ISBN:</label>
    <input type="text" id="book_isbn" name="book_isbn" value="<?php echo esc_attr( $isbn ); ?>" />
    <?php
}

function mypl_save_meta( $post_id ) {
    // Security checks
    if ( ! isset( $_POST['mypl_meta_nonce'] )
         || ! wp_verify_nonce( $_POST['mypl_meta_nonce'], 'mypl_save_meta' ) ) {
        return;
    }

    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }

    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    // Save data
    if ( isset( $_POST['book_isbn'] ) ) {
        update_post_meta(
            $post_id,
            '_book_isbn',
            sanitize_text_field( $_POST['book_isbn'] )
        );
    }
}
add_action( 'save_post_book', 'mypl_save_meta' );

Pattern 4: Settings API

function mypl_add_menu() {
    add_options_page(
        'My Plugin Settings',
        'My Plugin',
        'manage_options',
        'my-plugin',
        'mypl_settings_page'
    );
}
add_action( 'admin_menu', 'mypl_add_menu' );

function mypl_register_settings() {
    register_setting( 'mypl_options', 'mypl_api_key', array(
        'type'              => 'string',
        'sanitize_callback' => 'sanitize_text_field',
        'default'           => '',
    ) );

    add_settings_section(
        'mypl_section',
        'API Settings',
        'mypl_section_callback',
        'my-plugin'
    );

    add_settings_field(
        'mypl_api_key',
        'API Key',
        'mypl_field_callback',
        'my-plugin',
        'mypl_section'
    );
}
add_action( 'admin_init', 'mypl_register_settings' );

function mypl_section_callback() {
    echo '<p>Configure your API settings.</p>';
}

function mypl_field_callback() {
    $value = get_option( 'mypl_api_key' );
    ?>
    <input type="text" name="mypl_api_key" value="<?php echo esc_attr( $value ); ?>" />
    <?php
}

function mypl_settings_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form method="post" action="options.php">
            <?php
            settings_fields( 'mypl_options' );
            do_settings_sections( 'my-plugin' );
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

Pattern 5: REST API Endpoints

add_action( 'rest_api_init', function() {
    register_rest_route( 'myplugin/v1', '/data', array(
        'methods'             => WP_REST_Server::READABLE,
        'callback'            => 'mypl_rest_callback',
        'permission_callback' => function() {
            return current_user_can( 'edit_posts' );
        },
        'args'                => array(
            'id' => array(
                'required'          => true,
                'validate_callback' => function( $param ) {
                    return is_numeric( $param );
                },
                'sanitize_callback' => 'absint',
            ),
        ),
    ) );
} );

function mypl_rest_callback( $request ) {
    $id = $request->get_param( 'id' );

    // Process...

    return new WP_REST_Response( array(
        'success' => true,
        'data'    => $data,
    ), 200 );
}

Pattern 6: AJAX Handlers (Legacy)

// Enqueue script with localized data
function mypl_enqueue_ajax_script() {
    wp_enqueue_script( 'mypl-ajax', plugins_url( 'js/ajax.js', __FILE__ ), array( 'jquery' ), '1.0', true );

    wp_localize_script( 'mypl-ajax', 'mypl_ajax_object', array(
        'ajaxurl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'mypl-ajax-nonce' ),
    ) );
}
add_action( 'wp_enqueue_scripts', 'mypl_enqueue_ajax_script' );

// AJAX handler (logged-in users)
function mypl_ajax_handler() {
    check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );

    $data = sanitize_text_field( $_POST['data'] );

    // Process...

    wp_send_json_success( array( 'message' => 'Success' ) );
}
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );

// AJAX handler (logged-out users)
add_action( 'wp_ajax_nopriv_mypl_action', 'mypl_ajax_handler' );

JavaScript (js/ajax.js):

jQuery(document).ready(function($) {
    $('#my-button').on('click', function() {
        $.ajax({
            url: mypl_ajax_object.ajaxurl,
            type: 'POST',
            data: {
                action: 'mypl_action',
                nonce: mypl_ajax_object.nonce,
                data: 'value'
            },
            success: function(response) {
                console.log(response.data.message);
            }
        });
    });
});

Pattern 7: Custom Database Tables

function mypl_create_tables() {
    global $wpdb;

    $table_name = $wpdb->prefix . 'mypl_data';
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table_name (
        id bigint(20) NOT NULL AUTO_INCREMENT,
        user_id bigint(20) NOT NULL,
        data text NOT NULL,
        created datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
        PRIMARY KEY  (id),
        KEY user_id (user_id)
    ) $charset_collate;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( $sql );

    add_option( 'mypl_db_version', '1.0' );
}

// Create tables on activation
register_activation_hook( __FILE__, 'mypl_create_tables' );

Pattern 8: Transients for Caching

function mypl_get_expensive_data() {
    // Try to get cached data
    $data = get_transient( 'mypl_expensive_data' );

    if ( false === $data ) {
        // Not cached - regenerate
        $data = perform_expensive_operation();

        // Cache for 12 hours
        set_transient( 'mypl_expensive_data', $data, 12 * HOUR_IN_SECONDS );
    }

    return $data;
}

// Clear cache when data changes
function mypl_clear_cache() {
    delete_transient( 'mypl_expensive_data' );
}
add_action( 'save_post', 'mypl_clear_cache' );

Using Bundled Resources

Templates (templates/)

Use these production-ready templates to scaffold plugins quickly:

  • templates/plugin-simple/ - Simple plugin with functions
  • templates/plugin-oop/ - Object-oriented plugin structure
  • templates/plugin-psr4/ - Modern PSR-4 plugin with Composer
  • templates/examples/meta-box.php - Meta box implementation
  • templates/examples/settings-page.php - Settings API page
  • templates/examples/custom-post-type.php - CPT registration
  • templates/examples/rest-endpoint.php - REST API endpoint
  • templates/examples/ajax-handler.php - AJAX implementation

When Claude should use these: When creating new plugins or implementing specific functionality patterns.

Scripts (scripts/)

  • scripts/scaffold-plugin.sh - Interactive plugin scaffolding
  • scripts/check-security.sh - Security audit for common issues
  • scripts/validate-headers.sh - Verify plugin headers

Example Usage:

# Scaffold new plugin
./scripts/scaffold-plugin.sh my-plugin simple

# Check for security issues
./scripts/check-security.sh my-plugin.php

# Validate plugin headers
./scripts/validate-headers.sh my-plugin.php

References (references/)

Detailed documentation that Claude can load when needed:

  • references/security-checklist.md - Complete security audit checklist
  • references/hooks-reference.md - Common WordPress hooks and filters
  • references/sanitization-guide.md - All sanitization/escaping functions
  • references/wpdb-patterns.md - Database query patterns
  • references/common-errors.md - Extended error prevention guide

When Claude should load these: When dealing with security issues, choosing the right hook, sanitizing specific data types, writing database queries, or debugging common errors.


Advanced Topics

Internationalization (i18n)

// Load text domain
function mypl_load_textdomain() {
    load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', 'mypl_load_textdomain' );

// Translatable strings
__( 'Text', 'my-plugin' );  // Returns translated string
_e( 'Text', 'my-plugin' );  // Echoes translated string
_n( 'One item', '%d items', $count, 'my-plugin' );  // Plural forms
esc_html__( 'Text', 'my-plugin' );  // Translate and escape
esc_html_e( 'Text', 'my-plugin' );  // Translate, escape, and echo

WP-CLI Commands

if ( defined( 'WP_CLI' ) && WP_CLI ) {

    class MyPL_CLI_Command {

        /**
         * Process data
         *
         * ## EXAMPLES
         *
         *     wp mypl process --limit=100
         *
         * @param array $args
         * @param array $assoc_args
         */
        public function process( $args, $assoc_args ) {
            $limit = isset( $assoc_args['limit'] ) ? absint( $assoc_args['limit'] ) : 10;

            WP_CLI::line( "Processing $limit items..." );

            // Process...

            WP_CLI::success( 'Processing complete!' );
        }
    }

    WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}

Scheduled Events (Cron)

// Schedule event on activation
function mypl_activate() {
    if ( ! wp_next_scheduled( 'mypl_daily_task' ) ) {
        wp_schedule_event( time(), 'daily', 'mypl_daily_task' );
    }
}
register_activation_hook( __FILE__, 'mypl_activate' );

// Clear event on deactivation
function mypl_deactivate() {
    wp_clear_scheduled_hook( 'mypl_daily_task' );
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );

// Hook to scheduled event
function mypl_do_daily_task() {
    // Perform task
}
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );

Plugin Dependencies Check

add_action( 'admin_init', function() {
    // Check for WooCommerce
    if ( ! class_exists( 'WooCommerce' ) ) {
        deactivate_plugins( plugin_basename( __FILE__ ) );

        add_action( 'admin_notices', function() {
            echo '<div class="error"><p><strong>My Plugin</strong> requires WooCommerce to be installed and active.</p></div>';
        } );

        if ( isset( $_GET['activate'] ) ) {
            unset( $_GET['activate'] );
        }
    }
} );

Distribution & Auto-Updates

Enabling GitHub Auto-Updates

Plugins hosted outside WordPress.org can still provide automatic updates using Plugin Update Checker by YahnisElsts. This is the recommended solution for most use cases.

Quick Start:

// 1. Install library (git submodule or Composer)
git submodule add https://github.com/YahnisElsts/plugin-update-checker.git

// 2. Add to main plugin file
require plugin_dir_path( __FILE__ ) . 'plugin-update-checker/plugin-update-checker.php';
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;

$updateChecker = PucFactory::buildUpdateChecker(
    'https://github.com/yourusername/your-plugin/',
    __FILE__,
    'your-plugin-slug'
);

// Use GitHub Releases (recommended)
$updateChecker->getVcsApi()->enableReleaseAssets();

// For private repos, use token from wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
    $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}

Deployment:

# 1. Update version in plugin header
# 2. Commit and tag
git add my-plugin.php
git commit -m "Bump version to 1.0.1"
git tag 1.0.1
git push origin main
git push origin 1.0.1

# 3. Create GitHub Release (optional but recommended)
# - Upload pre-built ZIP file (exclude .git, tests, etc.)
# - Add release notes for users

Key Features:

Works with GitHub, GitLab, BitBucket, or custom servers Supports public and private repositories Uses GitHub Releases or tags for versioning Secure HTTPS-based updates Optional license key integration Professional release notes and changelogs ~100KB library footprint

Alternative Solutions:

  1. Git Updater (user-installable plugin, no coding required)
  2. Custom Update Server (full control, requires hosting)
  3. Freemius (commercial, includes licensing and payments)

Comprehensive Resources:

  • Complete Guide: See references/github-auto-updates.md (21 pages, all approaches)
  • Implementation Examples: See examples/github-updater.php (10 examples)
  • Security Best Practices: Checksums, signing, token storage, rate limiting
  • Template Integration: All 3 plugin templates include setup instructions

Security Considerations:

  • Always use HTTPS for repository URLs
  • Never hardcode authentication tokens (use wp-config.php)
  • Implement license validation before offering updates
  • Optional: Add checksums for file verification
  • Rate limit update checks to avoid API throttling
  • Clear cached update data after installation

When to Use Each Approach:

Use Case Recommended Solution
Open source, public repo Plugin Update Checker
Private plugin, client work Plugin Update Checker + private repo
Commercial plugin Freemius or Custom Server
Multi-platform Git hosting Git Updater
Custom licensing needs Custom Update Server

ZIP Structure Requirement:

plugin.zip
└── my-plugin/       ← Plugin folder MUST be inside ZIP
    ├── my-plugin.php
    ├── readme.txt
    └── ...

Incorrect structure will cause WordPress to create a random folder name and break the plugin!


Dependencies

Required:

  • WordPress 5.9+ (recommend 6.7+)
  • PHP 7.4+ (recommend 8.0+)

Optional:

  • Composer 2.0+ - For PSR-4 autoloading
  • WP-CLI 2.0+ - For command-line plugin management
  • Query Monitor - For debugging and performance analysis

Official Documentation


Troubleshooting

Problem: Plugin causes fatal error

Solution:

  1. Enable WP_DEBUG in wp-config.php
  2. Check error log at wp-content/debug.log
  3. Verify all class/function names are prefixed
  4. Check for missing dependencies

Problem: 404 errors on custom post type pages

Solution: Flush rewrite rules

// Temporarily add to wp-admin
flush_rewrite_rules();
// Remove after visiting wp-admin once

Problem: Nonce verification always fails

Solution:

  1. Check nonce name matches in field and verification
  2. Verify using correct action name
  3. Ensure nonce hasn't expired (24 hour default)

Problem: AJAX returns 0 or -1

Solution:

  1. Verify action name matches hook: wp_ajax_{action}
  2. Check nonce is being sent and verified
  3. Ensure handler function exists and is hooked correctly

Problem: Sanitization stripping HTML

Solution: Use wp_kses_post() instead of sanitize_text_field() to allow safe HTML

Problem: Database queries not working

Solution:

  1. Always use $wpdb->prepare() for queries with variables
  2. Check table name includes $wpdb->prefix
  3. Verify column names and syntax

Complete Setup Checklist

Use this checklist to verify your plugin:

  • Plugin header complete with all fields
  • ABSPATH check at top of every PHP file
  • All functions/classes use unique prefix
  • All forms have nonce verification
  • All user input is sanitized
  • All output is escaped
  • All database queries use $wpdb->prepare()
  • Capability checks (not just is_admin())
  • Custom post types flush rewrite rules on activation
  • Deactivation hook only clears temporary data
  • uninstall.php handles permanent cleanup
  • Text domain matches plugin slug
  • Scripts/styles only load where needed
  • WP_DEBUG enabled during development
  • Tested with Query Monitor for performance
  • No deprecated function warnings
  • Works with latest WordPress version

Questions? Issues?

  1. Check references/common-errors.md for extended troubleshooting
  2. Verify all steps in the security foundation
  3. Check official docs: https://developer.wordpress.org/plugins/
  4. Enable WP_DEBUG and check debug.log
  5. Use Query Monitor plugin to debug hooks and queries