WordPress Plugin Development (Core)

SkillDatabases & data

Build secure WordPress plugins with hooks, database interactions, Settings API, custom post types, and REST API. Covers Simple, OOP, and PSR-4 architecture patterns plus the Security Trinity. Includes WordPress 6.7-6.9 breaking changes.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the WordPress Plugin Development (Core) skill

What this skill tells your AI

The instructions your AI receives, as published by dennislee928/ethic-latex in .claude/skills/wordpress-plugin-core/SKILL.md and read by ahel’s review.

Last Updated: 2026-01-21 Latest Versions: WordPress 6.9+ (Dec 2, 2025), PHP 8.0+ recommended, PHP 8.5 compatible Dependencies: None (WordPress 5.9+, PHP 7.4+ minimum)


Quick Start

Architecture Patterns: Simple (functions only, <5 functions) | OOP (medium plugins) | PSR-4 (modern/large, recommended 2025+)

Plugin Header (only Plugin Name required):

<?php
/**
 * Plugin Name: My Plugin
 * Version: 1.0.0
 * Requires at least: 5.9
 * Requires PHP: 7.4
 * Text Domain: my-plugin
 */

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

Security Foundation (5 essentials before writing functionality):

// 1. Unique Prefix
define( 'MYPL_VERSION', '1.0.0' );
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );

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

// 3. Nonces
wp_nonce_field( 'mypl_action', 'mypl_nonce' );
wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' );

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

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

Security Foundation (Detailed)

Unique Prefix (4-5 chars minimum)

Apply to: functions, classes, constants, options, transients, meta keys. Avoid: wp_, __, _.

function mypl_function() {}  // ✅
class MyPL_Class {}          // ✅
function init() {}           // ❌ Will conflict

Capabilities Check (Not is_admin())

// ❌ WRONG - Security hole
if ( is_admin() ) { /* delete data */ }

// ✅ CORRECT
if ( current_user_can( 'manage_options' ) ) { /* delete data */ }

Common: manage_options (Admin), edit_posts (Editor/Author), read (Subscriber)

Security Trinity (Input → Processing → Output)

// Sanitize INPUT
$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );
$html = wp_kses_post( $_POST['content'] );  // Allow safe HTML
$ids = array_map( 'absint', $_POST['ids'] );

// Validate LOGIC
if ( ! is_email( $email ) ) wp_die( 'Invalid' );

// Escape OUTPUT
echo esc_html( $name );
echo '<a href="' . esc_url( $url ) . '">';
echo '<div class="' . esc_attr( $class ) . '">';

Nonces (CSRF Protection)

// Form
<?php wp_nonce_field( 'mypl_action', 'mypl_nonce' ); ?>
if ( ! wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' ) ) wp_die( 'Failed' );

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

Prepared Statements

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

// ✅ Prepared (%s=String, %d=Integer, %f=Float)
$wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}table WHERE id = %d", $_GET['id'] ) );

// LIKE Queries
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare( "... WHERE 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 29 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 (and Performance)

Error: Custom post types return 404 errors, or database overload from repeated flushing Source: WordPress Plugin Handbook, Permalink Manager Pro Why It Happens: Forgot to flush rewrite rules after registering CPT, OR calling flush on every page load Prevention: Flush ONLY on activation/deactivation, NEVER on every page load

// ✅ CORRECT - Only flush 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' );

// ❌ WRONG - Causes database overload on EVERY page load
add_action( 'init', 'mypl_register_cpt' );
add_action( 'init', 'flush_rewrite_rules' );  // BAD! Performance killer!

// ❌ WRONG - In functions.php
function mypl_register_cpt() {
    register_post_type( 'book', ... );
    flush_rewrite_rules();  // BAD! Runs every time
}

User-Facing Fix: If CPT shows 404, manually flush by going to Settings → Permalinks → Save Changes.

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, allowing unauthorized access or privilege escalation Source: WordPress REST API Handbook, Patchstack CVE Database Why It Happens: No permission_callback specified, or missing show_in_index => false for sensitive endpoints Prevention: Always add permission_callback AND hide sensitive endpoints from REST index

Real 2025-2026 Vulnerabilities:

  • All in One SEO (3M+ sites): Missing permission check allowed contributor-level users to view global AI access token
  • AI Engine Plugin (CVE-2025-11749, CVSS 9.8 Critical): Failed to set show_in_index => false, exposed bearer token in /wp-json/ index, full admin privileges granted to unauthenticated attackers
  • SureTriggers: Insufficient authorization checks exploited within 4 hours of disclosure
  • Worker for Elementor (CVE-2025-66144): Subscriber-level privileges could invoke restricted features
// ❌ VULNERABLE - Missing permission_callback (WordPress 5.5+ requires it!)
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'  => 'GET',
    'callback' => 'my_callback',
) );

// ✅ SECURE - Basic protection
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'             => 'GET',
    'callback'            => 'my_callback',
    'permission_callback' => function() {
        return current_user_can( 'edit_posts' );
    },
) );

// ✅ SECURE - Hide sensitive endpoints from REST index
register_rest_route( 'myplugin/v1', '/admin', array(
    'methods'             => 'POST',
    'callback'            => 'my_admin_callback',
    'permission_callback' => function() {
        return current_user_can( 'manage_options' );
    },
    'show_in_index'       => false,  // Don't expose in /wp-json/
) );

2025-2026 Statistics: 64,782 total vulnerabilities tracked, 333 new in one week, 236 remained unpatched. REST API auth issues represent significant percentage.

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' );
        },
    ) );
} );

Issue #21: Missing show_in_rest for Block Editor

Error: Custom post types show classic editor instead of Gutenberg block editor Source: WordPress VIP Documentation, GitHub Issue #7595 Why It Happens: Forgot to set show_in_rest => true when registering custom post type Prevention: Always include show_in_rest for CPTs that need block editor

// ❌ WRONG - Block editor won't work
register_post_type( 'book', array(
    'public' => true,
    'supports' => array('editor'),
    // Missing show_in_rest!
) );

// ✅ CORRECT
register_post_type( 'book', array(
    'public' => true,
    'show_in_rest' => true,  // Required for block editor
    'supports' => array('editor'),
) );

Critical Rule: Only post types registered with 'show_in_rest' => true are compatible with the block editor. The block editor is dependent on the WordPress REST API. For post types that are incompatible with the block editor—or have show_in_rest => false—the classic editor will load instead.

Issue #22: wpdb::prepare() Table Name Escaping

Error: SQL syntax error from quoted table names, or hardcoded prefix breaks on different installations Source: WordPress Coding Standards Issue #2442 Why It Happens: Using table names as placeholders adds quotes around the table name Prevention: Table names must NOT be in prepare() placeholders

// ❌ WRONG - Adds quotes around table name
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM %s WHERE id = %d",
    $table, $id
) );
// Result: SELECT * FROM 'wp_my_table' WHERE id = 1
// FAILS - table name is quoted

// ❌ WRONG - Hardcoded prefix
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM wp_my_table WHERE id = %d",
    $id
) );
// FAILS if user changed table prefix

// ✅ CORRECT - Table name NOT in prepare()
$table = $wpdb->prefix . 'my_table';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$table} WHERE id = %d",
    $id
) );

// ✅ CORRECT - Using wpdb->prefix for built-in tables
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $id
) );

Issue #23: Nonce Verification Edge Cases

Error: Confusing user experience from nonce failures, or false sense of security Source: MalCare: wp_verify_nonce(), Pressidium: Understanding Nonces Why It Happens: Misunderstanding nonce behavior and limitations Prevention: Understand nonce edge cases and always combine with capability checks

Edge Cases:

  1. Time-Based Return Values:
$result = wp_verify_nonce( $nonce, 'action' );
// Returns 1: Valid, generated 0-12 hours ago
// Returns 2: Valid, generated 12-24 hours ago
// Returns false: Invalid or expired
  1. Nonce Reusability: WordPress doesn't track if a nonce has been used. They can be used multiple times within the 12-24 hour window.

  2. Session Invalidation: A nonce is only valid when tied to a valid session. If a user logs out, all their nonces become invalid, causing confusing UX if they had a form open.

  3. Caching Problems: Cache issues can cause mismatches when caching plugins serve an older nonce.

  4. NOT a Substitute for Authorization:

// ❌ INSUFFICIENT - Only checks origin, not permission
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) ) {
    delete_user( $_POST['user_id'] );
}

// ✅ CORRECT - Combine with capability check
if ( wp_verify_nonce( $_POST['nonce'], 'delete_user' ) &&
     current_user_can( 'delete_users' ) ) {
    delete_user( absint( $_POST['user_id'] ) );
}

Key Principle (2025): Nonces should never be relied on for authentication or authorization. Always assume nonces can be compromised. Protect your functions using current_user_can().

Issue #24: Hook Priority and Argument Count

Error: Hook callback doesn't receive expected arguments, or runs in wrong order Source: Kinsta: WordPress Hooks Bootcamp Why It Happens: Default is only 1 argument, priority defaults to 10 Prevention: Specify argument count and priority explicitly when needed

// ❌ WRONG - Only receives $post_id
add_action( 'save_post', 'my_save_function' );
function my_save_function( $post_id, $post, $update ) {
    // $post and $update are NULL!
}

// ✅ CORRECT - Specify argument count
add_action( 'save_post', 'my_save_function', 10, 3 );
function my_save_function( $post_id, $post, $update ) {
    // Now all 3 arguments are available
}

// Priority matters (lower number = runs earlier)
add_action( 'init', 'first_function', 5 );   // Runs first
add_action( 'init', 'second_function', 10 );  // Default priority
add_action( 'init', 'third_function', 15 );   // Runs last

Best Practices:

  • Always prefix custom hook names to avoid collisions: do_action( 'mypl_data_processed' ) not do_action( 'data_processed' )
  • Filters must RETURN modified data, not echo it
  • Hook placement affects backwards compatibility - choose carefully

Issue #25: Custom Post Type URL Conflicts

Error: Individual CPT posts return 404 errors despite permalinks flushed Source: Permalink Manager Pro: URL Conflicts Why It Happens: CPT slug matches a page slug, creating URL conflict Prevention: Use different slug for CPT or rename the page

// ❌ CONFLICT - Page and CPT use same slug
// Page URL: example.com/portfolio/
register_post_type( 'portfolio', array(
    'rewrite' => array( 'slug' => 'portfolio' ),
) );
// Individual posts 404: example.com/portfolio/my-project/

// ✅ SOLUTION 1 - Use different slug for CPT
register_post_type( 'portfolio', array(
    'rewrite' => array( 'slug' => 'projects' ),
) );
// Posts: example.com/projects/my-project/
// Page: example.com/portfolio/

// ✅ SOLUTION 2 - Use hierarchical slug
register_post_type( 'portfolio', array(
    'rewrite' => array( 'slug' => 'work/portfolio' ),
) );
// Posts: example.com/work/portfolio/my-project/

// ✅ SOLUTION 3 - Rename the page slug
// Change page from /portfolio/ to /our-portfolio/

Issue #26: WordPress 6.8 bcrypt Password Hashing Migration

Error: Custom password hash handling breaks after WordPress 6.8 upgrade Source: WordPress Core Make, GitHub Issue #21022 Why It Happens: WordPress 6.8+ switched from phpass to bcrypt password hashing Prevention: Use WordPress password functions, don't handle hashes directly

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
53
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
wordpress-plugin-core
Source
github.com/dennislee928/ethic-latex