(SHA-384 pre-hashed bcrypt)\n- Existing passwords automatically rehashed on next login\n- Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant\n\n```\n// ✅ SAFE - These functions continue to work without changes\nwp_hash_password( $password );\nwp_check_password( $password, $hash );\n\n// ⚠️ NEEDS UPDATE - Direct phpass hash handling\nif ( strpos( $hash, '$P

wordpress-plugin-core

$npx skills add jezweb/claude-skills --skill wordpress-plugin-core
SKILL.md

Wordpress Plugin Core

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

WordPress Plugin Development (Core)

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>';
`### 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/\_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>';

// SECURE
echo esc_html( $_POST['name'] );
echo '<div>';

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 wpdbDocumentationWhyItHappens:LIKEwildcardsnotescapedproperlyPrevention:Usewpdb 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><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
What Changed (WordPress 6.8, April 2025):
  • Default password hashing algorithm changed from phpass to bcrypt
  • New hash prefix: $wp$2y$ (SHA-384 pre-hashed bcrypt)
  • Existing passwords automatically rehashed on next login
  • Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant
// ✅ SAFE - These functions continue to work without changes
wp_hash_password( $password );
wp_check_password( $password, $hash );

// ⚠️ NEEDS UPDATE - Direct phpass hash handling
if ( strpos( $hash, '$P
#x27; ) === 0 ) { // Custom phpass logic - needs update for bcrypt } // ✅ NEW - Detect hash type if ( strpos( $hash, '$wp$2y
#x27; ) === 0 ) { // bcrypt hash (WordPress 6.8+) } elseif ( strpos( $hash, '$P
#x27; ) === 0 ) { // phpass hash (WordPress <6.8) }
Action Required:
  • Review plugins that directly handle password hashes
  • Remove bcrypt plugins when upgrading to 6.8+
  • No action needed for standard wp_hash_password/wp_check_password usage

Issue #27: WordPress 6.9 WP_Dependencies Deprecation

Error: "Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" Source: WordPress 6.9 Documentation, WordPress Support Forum Why It Happens: WordPress 6.9 (Dec 2, 2025) deprecated WP_Dependencies object methods Prevention: Test plugins with WP_DEBUG enabled on WordPress 6.9, replace deprecated methods
Affected Plugins (confirmed):
  • WooCommerce (fixed in 10.4.2)
  • Yoast SEO (fixed in 26.6)
  • Elementor (requires 3.24+)
Breaking Changes: WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.
Action Required:
  • Test plugins with WP_DEBUG enabled on WordPress 6.9
  • Replace deprecated WP_Dependencies methods
  • Check for deprecation notices in debug.log
  • While top 1,000 plugins patched within hours, unmaintained plugins often lag behind

Issue #28: Translation Loading Changes in WordPress 6.7

Error: Translations don't load or debug notices appear Source: WooCommerce Developer Blog, WordPress 6.7 Field Guide Why It Happens: WordPress 6.7+ changed when/how translations load Prevention: Load translations after 'init' priority 10, ensure text domain matches plugin slug
// ❌ WRONG - Loading too early
add_action( 'init', 'load_plugin_textdomain' );

// ✅ CORRECT - Load after 'init' priority 10
add_action( 'init', 'load_plugin_textdomain', 11 );

// Ensure text domain matches plugin slug EXACTLY
// Plugin header: Text Domain: my-plugin
__( 'Text', 'my-plugin' );  // Must match exactly
Action Required:
  • Review when load_plugin_textdomain() is called
  • Ensure text domain matches plugin slug exactly
  • Test with WP_DEBUG enabled

Issue #29: wpdb::prepare() Missing Placeholders Error

Error: "The query argument of wpdb::prepare() must have a placeholder" Source: WordPress $wpdb Documentation, SitePoint: Working with Databases Why It Happens: Using prepare() without any placeholders Prevention: Don't use prepare() if no dynamic data
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// Error: The query argument of wpdb::prepare() must have a placeholder

// ✅ CORRECT - Don't use prepare() if no dynamic data
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );

// ✅ CORRECT - Use prepare() for dynamic data
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $post_id
) );
Additional wpdb::prepare() Mistakes:
  1. Percentage Sign Handling:
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'" );

// ✅ CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    $search
) );
`2.  **Mixing Argument Formats**:`
// ❌ WRONG - Can't mix individual args and array
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, array( $name ) );

// ✅ CORRECT - Pick one format
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, $name );
// OR
$wpdb->prepare( "... WHERE id = %d AND name = %s", array( $id, $name ) );

Plugin Architecture Patterns

Simple (Functions Only)

Small plugins (<5 functions):
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );

OOP (Singleton)

Medium plugins:
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() {
        add_action( 'init', array( $this, 'init' ) );
    }
}
MyPL_Plugin::get_instance();

PSR-4 (Modern, Recommended 2025+)

Large/team plugins:
my-plugin/
├── my-plugin.php
├── composer.json → "psr-4": { "MyPlugin\\": "src/" }
└── src/Admin.php

// my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
new Admin();

Common Patterns

Custom Post Types (CRITICAL: Flush rewrite rules on activation, show_in_rest for block editor):
// show_in_rest => true REQUIRED for Gutenberg block editor
register_post_type( 'book', array(
    'public' => true,
    'show_in_rest' => true,  // Without this, block editor won't work!
    'supports' => array( 'editor', 'title' ),
) );
register_activation_hook( __FILE__, function() {
    mypl_register_cpt();
    flush_rewrite_rules();  // NEVER call on every page load
} );
`**Custom Taxonomies**:`
register_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );
`**Meta Boxes**:`
add_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );
// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')
update_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );
`**Settings API**:`
register_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
add_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );
add_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );
`**REST API** (10x faster than admin-ajax.php):`
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'             => 'POST',
    'callback'            => 'mypl_rest_callback',
    'permission_callback' => fn() => current_user_can( 'edit_posts' ),
) );
`**AJAX** (Legacy, use REST API for new projects):`
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_send_json_success( array( 'message' => 'Success' ) );
`**Custom Tables**:`
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
`**Transients** (Caching):`
$data = get_transient( 'mypl_data' );
if ( false === $data ) {
    $data = expensive_operation();
    set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );
}

Bundled Resources

Templates: plugin-simple/, plugin-oop/, plugin-psr4/, examples/meta-box.php, examples/settings-page.php, examples/custom-post-type.php, examples/rest-endpoint.php, examples/ajax-handler.php
Scripts: scaffold-plugin.sh, check-security.sh, validate-headers.sh
References: security-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.md

Advanced Topics

i18n (Internationalization):
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
__( 'Text', 'my-plugin' );  // Return translated
_e( 'Text', 'my-plugin' );  // Echo translated
esc_html__( 'Text', 'my-plugin' );  // Translate + escape
`**WP-CLI**:`
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
`**Cron Events**:`
register_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );
register_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
`**Plugin Dependencies**:`
if ( ! class_exists( 'WooCommerce' ) ) {
    deactivate_plugins( plugin_basename( __FILE__ ) );
    add_action( 'admin_notices', fn() => echo '<div><p>Requires WooCommerce</p></div>' );
}

Distribution & Auto-Updates

GitHub Auto-Updates (Plugin Update Checker by YahnisElsts):
// 1. Install: 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'
);
$updateChecker->getVcsApi()->enableReleaseAssets();  // Use GitHub Releases

// Private repos: Define token in wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
    $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
`**Deployment**:`
git tag 1.0.1 && git push origin main && git push origin 1.0.1
# Create GitHub Release with ZIP (exclude .git, tests)
Alternatives: Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)
Security: Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks
CRITICAL: ZIP must contain plugin folder: plugin.zip/my-plugin/my-plugin.php
Resources: See references/github-auto-updates.md, examples/github-updater.php

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

Fatal Error: Enable WP_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies
404 on CPT: Flush rewrite rules via Settings → Permalinks → Save
Nonce Fails: Check nonce name/action match, verify not expired (24h default)
AJAX Returns 0/-1: Verify action name matches wp_ajax_{action}, check nonce sent/verified
HTML Stripped: Use wp_kses_post() not sanitize_text_field() for safe HTML
Query Fails: Use $wpdb->prepare(), check $wpdb->prefix, verify 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

Last verified: 2026-01-21 | Skill version: 2.0.0 | Changes: Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).
) === 0 ) {\n // Custom phpass logic - needs update for bcrypt\n}\n\n// ✅ NEW - Detect hash type\nif ( strpos( $hash, '$wp$2y

wordpress-plugin-core

$npx skills add jezweb/claude-skills --skill wordpress-plugin-core
SKILL.md

Wordpress Plugin Core

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

WordPress Plugin Development (Core)

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>';
`### 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/\_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>';

// SECURE
echo esc_html( $_POST['name'] );
echo '<div>';

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 wpdbDocumentationWhyItHappens:LIKEwildcardsnotescapedproperlyPrevention:Usewpdb 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><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
What Changed (WordPress 6.8, April 2025):
  • Default password hashing algorithm changed from phpass to bcrypt
  • New hash prefix: $wp$2y$ (SHA-384 pre-hashed bcrypt)
  • Existing passwords automatically rehashed on next login
  • Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant
// ✅ SAFE - These functions continue to work without changes
wp_hash_password( $password );
wp_check_password( $password, $hash );

// ⚠️ NEEDS UPDATE - Direct phpass hash handling
if ( strpos( $hash, '$P
#x27; ) === 0 ) { // Custom phpass logic - needs update for bcrypt } // ✅ NEW - Detect hash type if ( strpos( $hash, '$wp$2y
#x27; ) === 0 ) { // bcrypt hash (WordPress 6.8+) } elseif ( strpos( $hash, '$P
#x27; ) === 0 ) { // phpass hash (WordPress <6.8) }
Action Required:
  • Review plugins that directly handle password hashes
  • Remove bcrypt plugins when upgrading to 6.8+
  • No action needed for standard wp_hash_password/wp_check_password usage

Issue #27: WordPress 6.9 WP_Dependencies Deprecation

Error: "Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" Source: WordPress 6.9 Documentation, WordPress Support Forum Why It Happens: WordPress 6.9 (Dec 2, 2025) deprecated WP_Dependencies object methods Prevention: Test plugins with WP_DEBUG enabled on WordPress 6.9, replace deprecated methods
Affected Plugins (confirmed):
  • WooCommerce (fixed in 10.4.2)
  • Yoast SEO (fixed in 26.6)
  • Elementor (requires 3.24+)
Breaking Changes: WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.
Action Required:
  • Test plugins with WP_DEBUG enabled on WordPress 6.9
  • Replace deprecated WP_Dependencies methods
  • Check for deprecation notices in debug.log
  • While top 1,000 plugins patched within hours, unmaintained plugins often lag behind

Issue #28: Translation Loading Changes in WordPress 6.7

Error: Translations don't load or debug notices appear Source: WooCommerce Developer Blog, WordPress 6.7 Field Guide Why It Happens: WordPress 6.7+ changed when/how translations load Prevention: Load translations after 'init' priority 10, ensure text domain matches plugin slug
// ❌ WRONG - Loading too early
add_action( 'init', 'load_plugin_textdomain' );

// ✅ CORRECT - Load after 'init' priority 10
add_action( 'init', 'load_plugin_textdomain', 11 );

// Ensure text domain matches plugin slug EXACTLY
// Plugin header: Text Domain: my-plugin
__( 'Text', 'my-plugin' );  // Must match exactly
Action Required:
  • Review when load_plugin_textdomain() is called
  • Ensure text domain matches plugin slug exactly
  • Test with WP_DEBUG enabled

Issue #29: wpdb::prepare() Missing Placeholders Error

Error: "The query argument of wpdb::prepare() must have a placeholder" Source: WordPress $wpdb Documentation, SitePoint: Working with Databases Why It Happens: Using prepare() without any placeholders Prevention: Don't use prepare() if no dynamic data
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// Error: The query argument of wpdb::prepare() must have a placeholder

// ✅ CORRECT - Don't use prepare() if no dynamic data
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );

// ✅ CORRECT - Use prepare() for dynamic data
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $post_id
) );
Additional wpdb::prepare() Mistakes:
  1. Percentage Sign Handling:
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'" );

// ✅ CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    $search
) );
`2.  **Mixing Argument Formats**:`
// ❌ WRONG - Can't mix individual args and array
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, array( $name ) );

// ✅ CORRECT - Pick one format
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, $name );
// OR
$wpdb->prepare( "... WHERE id = %d AND name = %s", array( $id, $name ) );

Plugin Architecture Patterns

Simple (Functions Only)

Small plugins (<5 functions):
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );

OOP (Singleton)

Medium plugins:
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() {
        add_action( 'init', array( $this, 'init' ) );
    }
}
MyPL_Plugin::get_instance();

PSR-4 (Modern, Recommended 2025+)

Large/team plugins:
my-plugin/
├── my-plugin.php
├── composer.json → "psr-4": { "MyPlugin\\": "src/" }
└── src/Admin.php

// my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
new Admin();

Common Patterns

Custom Post Types (CRITICAL: Flush rewrite rules on activation, show_in_rest for block editor):
// show_in_rest => true REQUIRED for Gutenberg block editor
register_post_type( 'book', array(
    'public' => true,
    'show_in_rest' => true,  // Without this, block editor won't work!
    'supports' => array( 'editor', 'title' ),
) );
register_activation_hook( __FILE__, function() {
    mypl_register_cpt();
    flush_rewrite_rules();  // NEVER call on every page load
} );
`**Custom Taxonomies**:`
register_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );
`**Meta Boxes**:`
add_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );
// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')
update_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );
`**Settings API**:`
register_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
add_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );
add_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );
`**REST API** (10x faster than admin-ajax.php):`
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'             => 'POST',
    'callback'            => 'mypl_rest_callback',
    'permission_callback' => fn() => current_user_can( 'edit_posts' ),
) );
`**AJAX** (Legacy, use REST API for new projects):`
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_send_json_success( array( 'message' => 'Success' ) );
`**Custom Tables**:`
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
`**Transients** (Caching):`
$data = get_transient( 'mypl_data' );
if ( false === $data ) {
    $data = expensive_operation();
    set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );
}

Bundled Resources

Templates: plugin-simple/, plugin-oop/, plugin-psr4/, examples/meta-box.php, examples/settings-page.php, examples/custom-post-type.php, examples/rest-endpoint.php, examples/ajax-handler.php
Scripts: scaffold-plugin.sh, check-security.sh, validate-headers.sh
References: security-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.md

Advanced Topics

i18n (Internationalization):
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
__( 'Text', 'my-plugin' );  // Return translated
_e( 'Text', 'my-plugin' );  // Echo translated
esc_html__( 'Text', 'my-plugin' );  // Translate + escape
`**WP-CLI**:`
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
`**Cron Events**:`
register_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );
register_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
`**Plugin Dependencies**:`
if ( ! class_exists( 'WooCommerce' ) ) {
    deactivate_plugins( plugin_basename( __FILE__ ) );
    add_action( 'admin_notices', fn() => echo '<div><p>Requires WooCommerce</p></div>' );
}

Distribution & Auto-Updates

GitHub Auto-Updates (Plugin Update Checker by YahnisElsts):
// 1. Install: 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'
);
$updateChecker->getVcsApi()->enableReleaseAssets();  // Use GitHub Releases

// Private repos: Define token in wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
    $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
`**Deployment**:`
git tag 1.0.1 && git push origin main && git push origin 1.0.1
# Create GitHub Release with ZIP (exclude .git, tests)
Alternatives: Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)
Security: Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks
CRITICAL: ZIP must contain plugin folder: plugin.zip/my-plugin/my-plugin.php
Resources: See references/github-auto-updates.md, examples/github-updater.php

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

Fatal Error: Enable WP_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies
404 on CPT: Flush rewrite rules via Settings → Permalinks → Save
Nonce Fails: Check nonce name/action match, verify not expired (24h default)
AJAX Returns 0/-1: Verify action name matches wp_ajax_{action}, check nonce sent/verified
HTML Stripped: Use wp_kses_post() not sanitize_text_field() for safe HTML
Query Fails: Use $wpdb->prepare(), check $wpdb->prefix, verify 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

Last verified: 2026-01-21 | Skill version: 2.0.0 | Changes: Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).
) === 0 ) {\n // bcrypt hash (WordPress 6.8+)\n} elseif ( strpos( $hash, '$P

wordpress-plugin-core

$npx skills add jezweb/claude-skills --skill wordpress-plugin-core
SKILL.md

Wordpress Plugin Core

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

WordPress Plugin Development (Core)

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>';
`### 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/\_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>';

// SECURE
echo esc_html( $_POST['name'] );
echo '<div>';

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 wpdbDocumentationWhyItHappens:LIKEwildcardsnotescapedproperlyPrevention:Usewpdb 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><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
What Changed (WordPress 6.8, April 2025):
  • Default password hashing algorithm changed from phpass to bcrypt
  • New hash prefix: $wp$2y$ (SHA-384 pre-hashed bcrypt)
  • Existing passwords automatically rehashed on next login
  • Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant
// ✅ SAFE - These functions continue to work without changes
wp_hash_password( $password );
wp_check_password( $password, $hash );

// ⚠️ NEEDS UPDATE - Direct phpass hash handling
if ( strpos( $hash, '$P
#x27; ) === 0 ) { // Custom phpass logic - needs update for bcrypt } // ✅ NEW - Detect hash type if ( strpos( $hash, '$wp$2y
#x27; ) === 0 ) { // bcrypt hash (WordPress 6.8+) } elseif ( strpos( $hash, '$P
#x27; ) === 0 ) { // phpass hash (WordPress <6.8) }
Action Required:
  • Review plugins that directly handle password hashes
  • Remove bcrypt plugins when upgrading to 6.8+
  • No action needed for standard wp_hash_password/wp_check_password usage

Issue #27: WordPress 6.9 WP_Dependencies Deprecation

Error: "Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" Source: WordPress 6.9 Documentation, WordPress Support Forum Why It Happens: WordPress 6.9 (Dec 2, 2025) deprecated WP_Dependencies object methods Prevention: Test plugins with WP_DEBUG enabled on WordPress 6.9, replace deprecated methods
Affected Plugins (confirmed):
  • WooCommerce (fixed in 10.4.2)
  • Yoast SEO (fixed in 26.6)
  • Elementor (requires 3.24+)
Breaking Changes: WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.
Action Required:
  • Test plugins with WP_DEBUG enabled on WordPress 6.9
  • Replace deprecated WP_Dependencies methods
  • Check for deprecation notices in debug.log
  • While top 1,000 plugins patched within hours, unmaintained plugins often lag behind

Issue #28: Translation Loading Changes in WordPress 6.7

Error: Translations don't load or debug notices appear Source: WooCommerce Developer Blog, WordPress 6.7 Field Guide Why It Happens: WordPress 6.7+ changed when/how translations load Prevention: Load translations after 'init' priority 10, ensure text domain matches plugin slug
// ❌ WRONG - Loading too early
add_action( 'init', 'load_plugin_textdomain' );

// ✅ CORRECT - Load after 'init' priority 10
add_action( 'init', 'load_plugin_textdomain', 11 );

// Ensure text domain matches plugin slug EXACTLY
// Plugin header: Text Domain: my-plugin
__( 'Text', 'my-plugin' );  // Must match exactly
Action Required:
  • Review when load_plugin_textdomain() is called
  • Ensure text domain matches plugin slug exactly
  • Test with WP_DEBUG enabled

Issue #29: wpdb::prepare() Missing Placeholders Error

Error: "The query argument of wpdb::prepare() must have a placeholder" Source: WordPress $wpdb Documentation, SitePoint: Working with Databases Why It Happens: Using prepare() without any placeholders Prevention: Don't use prepare() if no dynamic data
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// Error: The query argument of wpdb::prepare() must have a placeholder

// ✅ CORRECT - Don't use prepare() if no dynamic data
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );

// ✅ CORRECT - Use prepare() for dynamic data
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $post_id
) );
Additional wpdb::prepare() Mistakes:
  1. Percentage Sign Handling:
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'" );

// ✅ CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    $search
) );
`2.  **Mixing Argument Formats**:`
// ❌ WRONG - Can't mix individual args and array
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, array( $name ) );

// ✅ CORRECT - Pick one format
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, $name );
// OR
$wpdb->prepare( "... WHERE id = %d AND name = %s", array( $id, $name ) );

Plugin Architecture Patterns

Simple (Functions Only)

Small plugins (<5 functions):
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );

OOP (Singleton)

Medium plugins:
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() {
        add_action( 'init', array( $this, 'init' ) );
    }
}
MyPL_Plugin::get_instance();

PSR-4 (Modern, Recommended 2025+)

Large/team plugins:
my-plugin/
├── my-plugin.php
├── composer.json → "psr-4": { "MyPlugin\\": "src/" }
└── src/Admin.php

// my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
new Admin();

Common Patterns

Custom Post Types (CRITICAL: Flush rewrite rules on activation, show_in_rest for block editor):
// show_in_rest => true REQUIRED for Gutenberg block editor
register_post_type( 'book', array(
    'public' => true,
    'show_in_rest' => true,  // Without this, block editor won't work!
    'supports' => array( 'editor', 'title' ),
) );
register_activation_hook( __FILE__, function() {
    mypl_register_cpt();
    flush_rewrite_rules();  // NEVER call on every page load
} );
`**Custom Taxonomies**:`
register_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );
`**Meta Boxes**:`
add_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );
// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')
update_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );
`**Settings API**:`
register_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
add_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );
add_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );
`**REST API** (10x faster than admin-ajax.php):`
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'             => 'POST',
    'callback'            => 'mypl_rest_callback',
    'permission_callback' => fn() => current_user_can( 'edit_posts' ),
) );
`**AJAX** (Legacy, use REST API for new projects):`
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_send_json_success( array( 'message' => 'Success' ) );
`**Custom Tables**:`
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
`**Transients** (Caching):`
$data = get_transient( 'mypl_data' );
if ( false === $data ) {
    $data = expensive_operation();
    set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );
}

Bundled Resources

Templates: plugin-simple/, plugin-oop/, plugin-psr4/, examples/meta-box.php, examples/settings-page.php, examples/custom-post-type.php, examples/rest-endpoint.php, examples/ajax-handler.php
Scripts: scaffold-plugin.sh, check-security.sh, validate-headers.sh
References: security-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.md

Advanced Topics

i18n (Internationalization):
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
__( 'Text', 'my-plugin' );  // Return translated
_e( 'Text', 'my-plugin' );  // Echo translated
esc_html__( 'Text', 'my-plugin' );  // Translate + escape
`**WP-CLI**:`
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
`**Cron Events**:`
register_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );
register_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
`**Plugin Dependencies**:`
if ( ! class_exists( 'WooCommerce' ) ) {
    deactivate_plugins( plugin_basename( __FILE__ ) );
    add_action( 'admin_notices', fn() => echo '<div><p>Requires WooCommerce</p></div>' );
}

Distribution & Auto-Updates

GitHub Auto-Updates (Plugin Update Checker by YahnisElsts):
// 1. Install: 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'
);
$updateChecker->getVcsApi()->enableReleaseAssets();  // Use GitHub Releases

// Private repos: Define token in wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
    $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
`**Deployment**:`
git tag 1.0.1 && git push origin main && git push origin 1.0.1
# Create GitHub Release with ZIP (exclude .git, tests)
Alternatives: Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)
Security: Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks
CRITICAL: ZIP must contain plugin folder: plugin.zip/my-plugin/my-plugin.php
Resources: See references/github-auto-updates.md, examples/github-updater.php

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

Fatal Error: Enable WP_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies
404 on CPT: Flush rewrite rules via Settings → Permalinks → Save
Nonce Fails: Check nonce name/action match, verify not expired (24h default)
AJAX Returns 0/-1: Verify action name matches wp_ajax_{action}, check nonce sent/verified
HTML Stripped: Use wp_kses_post() not sanitize_text_field() for safe HTML
Query Fails: Use $wpdb->prepare(), check $wpdb->prefix, verify 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

Last verified: 2026-01-21 | Skill version: 2.0.0 | Changes: Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).
) === 0 ) {\n // phpass hash (WordPress \u003c6.8)\n}\n```\n\n**Action Required**:\n\n- Review plugins that directly handle password hashes\n- Remove bcrypt plugins when upgrading to 6.8+\n- No action needed for standard wp\\_hash\\_password/wp\\_check\\_password usage\n\n### Issue #27: WordPress 6.9 WP\\_Dependencies Deprecation\n\n**Error**: \"Deprecated: Function WP\\_Dependencies->add\\_data() was called with an argument that is deprecated\" **Source**: [WordPress 6.9 Documentation](https://wordpress.org/documentation/wordpress-version/version-6-9/), [WordPress Support Forum](https://wordpress.org/support/topic/after-automatic-updating-to-6-9-deprecated-function-wp_dependencies/) **Why It Happens**: WordPress 6.9 (Dec 2, 2025) deprecated WP\\_Dependencies object methods **Prevention**: Test plugins with WP\\_DEBUG enabled on WordPress 6.9, replace deprecated methods\n\n**Affected Plugins** (confirmed):\n\n- WooCommerce (fixed in 10.4.2)\n- Yoast SEO (fixed in 26.6)\n- Elementor (requires 3.24+)\n\n**Breaking Changes**: WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.\n\n**Action Required**:\n\n- Test plugins with WP\\_DEBUG enabled on WordPress 6.9\n- Replace deprecated WP\\_Dependencies methods\n- Check for deprecation notices in debug.log\n- While top 1,000 plugins patched within hours, unmaintained plugins often lag behind\n\n### Issue #28: Translation Loading Changes in WordPress 6.7\n\n**Error**: Translations don't load or debug notices appear **Source**: [WooCommerce Developer Blog](https://developer.woocommerce.com/2024/11/11/developer-advisory-translation-loading-changes-in-wordpress-6-7/), [WordPress 6.7 Field Guide](https://make.wordpress.org/core/2024/10/23/wordpress-6-7-field-guide/) **Why It Happens**: WordPress 6.7+ changed when/how translations load **Prevention**: Load translations after 'init' priority 10, ensure text domain matches plugin slug\n\n```\n// ❌ WRONG - Loading too early\nadd_action( 'init', 'load_plugin_textdomain' );\n\n// ✅ CORRECT - Load after 'init' priority 10\nadd_action( 'init', 'load_plugin_textdomain', 11 );\n\n// Ensure text domain matches plugin slug EXACTLY\n// Plugin header: Text Domain: my-plugin\n__( 'Text', 'my-plugin' ); // Must match exactly\n```\n\n**Action Required**:\n\n- Review when load\\_plugin\\_textdomain() is called\n- Ensure text domain matches plugin slug exactly\n- Test with WP\\_DEBUG enabled\n\n### Issue #29: wpdb::prepare() Missing Placeholders Error\n\n**Error**: \"The query argument of wpdb::prepare() must have a placeholder\" **Source**: [WordPress $wpdb Documentation](https://developer.wordpress.org/reference/classes/wpdb/), [SitePoint: Working with Databases](https://www.sitepoint.com/working-with-databases-in-wordpress/) **Why It Happens**: Using prepare() without any placeholders **Prevention**: Don't use prepare() if no dynamic data\n\n```\n// ❌ WRONG\n$wpdb->prepare( \"SELECT * FROM {$wpdb->posts}\" );\n// Error: The query argument of wpdb::prepare() must have a placeholder\n\n// ✅ CORRECT - Don't use prepare() if no dynamic data\n$wpdb->get_results( \"SELECT * FROM {$wpdb->posts}\" );\n\n// ✅ CORRECT - Use prepare() for dynamic data\n$wpdb->get_results( $wpdb->prepare(\n \"SELECT * FROM {$wpdb->posts} WHERE ID = %d\",\n $post_id\n) );\n```\n\n**Additional wpdb::prepare() Mistakes**:\n\n1. **Percentage Sign Handling**:\n\n```\n// ❌ WRONG\n$wpdb->prepare( \"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'\" );\n\n// ✅ CORRECT\n$search = '%' . $wpdb->esc_like( $term ) . '%';\n$wpdb->get_results( $wpdb->prepare(\n \"SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s\",\n $search\n) );\n```\n\n2. **Mixing Argument Formats**:\n\n```\n// ❌ WRONG - Can't mix individual args and array\n$wpdb->prepare( \"... WHERE id = %d AND name = %s\", $id, array( $name ) );\n\n// ✅ CORRECT - Pick one format\n$wpdb->prepare( \"... WHERE id = %d AND name = %s\", $id, $name );\n// OR\n$wpdb->prepare( \"... WHERE id = %d AND name = %s\", array( $id, $name ) );\n```\n\n* * *\n\n## Plugin Architecture Patterns\n\n### Simple (Functions Only)\n\nSmall plugins (\u003c5 functions):\n\n```\nfunction mypl_init() { /* code */ }\nadd_action( 'init', 'mypl_init' );\n```\n\n### OOP (Singleton)\n\nMedium plugins:\n\n```\nclass MyPL_Plugin {\n private static $instance = null;\n public static function get_instance() {\n if ( null === self::$instance ) self::$instance = new self();\n return self::$instance;\n }\n private function __construct() {\n add_action( 'init', array( $this, 'init' ) );\n }\n}\nMyPL_Plugin::get_instance();\n```\n\n### PSR-4 (Modern, Recommended 2025+)\n\nLarge/team plugins:\n\n```\nmy-plugin/\n├── my-plugin.php\n├── composer.json → \"psr-4\": { \"MyPlugin\\\\\": \"src/\" }\n└── src/Admin.php\n\n// my-plugin.php\nrequire_once __DIR__ . '/vendor/autoload.php';\nuse MyPlugin\\Admin;\nnew Admin();\n```\n\n* * *\n\n## Common Patterns\n\n**Custom Post Types** (CRITICAL: Flush rewrite rules on activation, show\\_in\\_rest for block editor):\n\n```\n// show_in_rest => true REQUIRED for Gutenberg block editor\nregister_post_type( 'book', array(\n 'public' => true,\n 'show_in_rest' => true, // Without this, block editor won't work!\n 'supports' => array( 'editor', 'title' ),\n) );\nregister_activation_hook( __FILE__, function() {\n mypl_register_cpt();\n flush_rewrite_rules(); // NEVER call on every page load\n} );\n```\n\n**Custom Taxonomies**:\n\n```\nregister_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );\n```\n\n**Meta Boxes**:\n\n```\nadd_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );\n// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')\nupdate_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );\n```\n\n**Settings API**:\n\n```\nregister_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );\nadd_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );\nadd_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );\n```\n\n**REST API** (10x faster than admin-ajax.php):\n\n```\nregister_rest_route( 'myplugin/v1', '/data', array(\n 'methods' => 'POST',\n 'callback' => 'mypl_rest_callback',\n 'permission_callback' => fn() => current_user_can( 'edit_posts' ),\n) );\n```\n\n**AJAX** (Legacy, use REST API for new projects):\n\n```\nadd_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );\ncheck_ajax_referer( 'mypl-ajax-nonce', 'nonce' );\nwp_send_json_success( array( 'message' => 'Success' ) );\n```\n\n**Custom Tables**:\n\n```\nglobal $wpdb;\n$sql = \"CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)\";\nrequire_once ABSPATH . 'wp-admin/includes/upgrade.php';\ndbDelta( $sql );\n```\n\n**Transients** (Caching):\n\n```\n$data = get_transient( 'mypl_data' );\nif ( false === $data ) {\n $data = expensive_operation();\n set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );\n}\n```\n\n* * *\n\n## Bundled Resources\n\n**Templates**: `plugin-simple/`, `plugin-oop/`, `plugin-psr4/`, `examples/meta-box.php`, `examples/settings-page.php`, `examples/custom-post-type.php`, `examples/rest-endpoint.php`, `examples/ajax-handler.php`\n\n**Scripts**: `scaffold-plugin.sh`, `check-security.sh`, `validate-headers.sh`\n\n**References**: `security-checklist.md`, `hooks-reference.md`, `sanitization-guide.md`, `wpdb-patterns.md`, `common-errors.md`\n\n* * *\n\n## Advanced Topics\n\n**i18n** (Internationalization):\n\n```\nload_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );\n__( 'Text', 'my-plugin' ); // Return translated\n_e( 'Text', 'my-plugin' ); // Echo translated\nesc_html__( 'Text', 'my-plugin' ); // Translate + escape\n```\n\n**WP-CLI**:\n\n```\nif ( defined( 'WP_CLI' ) && WP_CLI ) {\n WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );\n}\n```\n\n**Cron Events**:\n\n```\nregister_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );\nregister_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );\nadd_action( 'mypl_daily_task', 'mypl_do_daily_task' );\n```\n\n**Plugin Dependencies**:\n\n```\nif ( ! class_exists( 'WooCommerce' ) ) {\n deactivate_plugins( plugin_basename( __FILE__ ) );\n add_action( 'admin_notices', fn() => echo '\u003cdiv>\u003cp>Requires WooCommerce\u003c/p>\u003c/div>' );\n}\n```\n\n* * *\n\n## Distribution & Auto-Updates\n\n**GitHub Auto-Updates** (Plugin Update Checker by YahnisElsts):\n\n```\n// 1. Install: git submodule add https://github.com/YahnisElsts/plugin-update-checker.git\n// 2. Add to main plugin file\nrequire plugin_dir_path( __FILE__ ) . 'plugin-update-checker/plugin-update-checker.php';\nuse YahnisElsts\\PluginUpdateChecker\\v5\\PucFactory;\n\n$updateChecker = PucFactory::buildUpdateChecker(\n 'https://github.com/yourusername/your-plugin/',\n __FILE__,\n 'your-plugin-slug'\n);\n$updateChecker->getVcsApi()->enableReleaseAssets(); // Use GitHub Releases\n\n// Private repos: Define token in wp-config.php\nif ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {\n $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );\n}\n```\n\n**Deployment**:\n\n```\ngit tag 1.0.1 && git push origin main && git push origin 1.0.1\n# Create GitHub Release with ZIP (exclude .git, tests)\n```\n\n**Alternatives**: Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)\n\n**Security**: Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks\n\n**CRITICAL**: ZIP must contain plugin folder: `plugin.zip/my-plugin/my-plugin.php`\n\n**Resources**: See `references/github-auto-updates.md`, `examples/github-updater.php`\n\n* * *\n\n## Dependencies\n\n**Required**:\n\n- WordPress 5.9+ (recommend 6.7+)\n- PHP 7.4+ (recommend 8.0+)\n\n**Optional**:\n\n- Composer 2.0+ - For PSR-4 autoloading\n- WP-CLI 2.0+ - For command-line plugin management\n- Query Monitor - For debugging and performance analysis\n\n* * *\n\n## Official Documentation\n\n- **WordPress Plugin Handbook**: [https://developer.wordpress.org/plugins/](https://developer.wordpress.org/plugins/)\n- **WordPress Coding Standards**: [https://developer.wordpress.org/coding-standards/](https://developer.wordpress.org/coding-standards/)\n- **WordPress REST API**: [https://developer.wordpress.org/rest-api/](https://developer.wordpress.org/rest-api/)\n- **WordPress Database Class ($wpdb)**: [https://developer.wordpress.org/reference/classes/wpdb/](https://developer.wordpress.org/reference/classes/wpdb/)\n- **WordPress Security**: [https://developer.wordpress.org/apis/security/](https://developer.wordpress.org/apis/security/)\n- **Settings API**: [https://developer.wordpress.org/plugins/settings/settings-api/](https://developer.wordpress.org/plugins/settings/settings-api/)\n- **Custom Post Types**: [https://developer.wordpress.org/plugins/post-types/](https://developer.wordpress.org/plugins/post-types/)\n- **Transients API**: [https://developer.wordpress.org/apis/transients/](https://developer.wordpress.org/apis/transients/)\n- **Context7 Library ID**: /websites/developer\\_wordpress\n\n* * *\n\n## Troubleshooting\n\n**Fatal Error**: Enable WP\\_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies\n\n**404 on CPT**: Flush rewrite rules via Settings → Permalinks → Save\n\n**Nonce Fails**: Check nonce name/action match, verify not expired (24h default)\n\n**AJAX Returns 0/-1**: Verify action name matches `wp_ajax_{action}`, check nonce sent/verified\n\n**HTML Stripped**: Use `wp_kses_post()` not `sanitize_text_field()` for safe HTML\n\n**Query Fails**: Use `$wpdb->prepare()`, check `$wpdb->prefix`, verify syntax\n\n* * *\n\n## Complete Setup Checklist\n\nUse this checklist to verify your plugin:\n\n- Plugin header complete with all fields\n- ABSPATH check at top of every PHP file\n- All functions/classes use unique prefix\n- All forms have nonce verification\n- All user input is sanitized\n- All output is escaped\n- All database queries use $wpdb->prepare()\n- Capability checks (not just is\\_admin())\n- Custom post types flush rewrite rules on activation\n- Deactivation hook only clears temporary data\n- uninstall.php handles permanent cleanup\n- Text domain matches plugin slug\n- Scripts/styles only load where needed\n- WP\\_DEBUG enabled during development\n- Tested with Query Monitor for performance\n- No deprecated function warnings\n- Works with latest WordPress version\n\n* * *\n\n**Questions? Issues?**\n\n1. Check `references/common-errors.md` for extended troubleshooting\n2. Verify all steps in the security foundation\n3. Check official docs: [https://developer.wordpress.org/plugins/](https://developer.wordpress.org/plugins/)\n4. Enable WP\\_DEBUG and check debug.log\n5. Use Query Monitor plugin to debug hooks and queries\n\n* * *\n\n**Last verified**: 2026-01-21 | **Skill version**: 2.0.0 | **Changes**: Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP\\_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).","repositoryUrl":"https://github.com/jezweb/claude-skills","originalUrl":"https://skills.sh/jezweb/claude-skills/wordpress-plugin-core","originalInstalls":316,"platformInstalls":0,"isVerified":true,"importedBy":null,"createdAt":"2026-02-10T03:38:59.966Z","updatedAt":null,"deletedAt":null,"totalInstalls":316}}

wordpress-plugin-core

$npx skills add jezweb/claude-skills --skill wordpress-plugin-core
SKILL.md

Wordpress Plugin Core

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

WordPress Plugin Development (Core)

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>';
`### 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/\_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>';

// SECURE
echo esc_html( $_POST['name'] );
echo '<div>';

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 wpdbDocumentationWhyItHappens:LIKEwildcardsnotescapedproperlyPrevention:Usewpdb 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><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
What Changed (WordPress 6.8, April 2025):
  • Default password hashing algorithm changed from phpass to bcrypt
  • New hash prefix: $wp$2y$ (SHA-384 pre-hashed bcrypt)
  • Existing passwords automatically rehashed on next login
  • Popular bcrypt plugins (roots/wp-password-bcrypt) now redundant
// ✅ SAFE - These functions continue to work without changes
wp_hash_password( $password );
wp_check_password( $password, $hash );

// ⚠️ NEEDS UPDATE - Direct phpass hash handling
if ( strpos( $hash, '$P
#x27; ) === 0 ) { // Custom phpass logic - needs update for bcrypt } // ✅ NEW - Detect hash type if ( strpos( $hash, '$wp$2y
#x27; ) === 0 ) { // bcrypt hash (WordPress 6.8+) } elseif ( strpos( $hash, '$P
#x27; ) === 0 ) { // phpass hash (WordPress <6.8) }
Action Required:
  • Review plugins that directly handle password hashes
  • Remove bcrypt plugins when upgrading to 6.8+
  • No action needed for standard wp_hash_password/wp_check_password usage

Issue #27: WordPress 6.9 WP_Dependencies Deprecation

Error: "Deprecated: Function WP_Dependencies->add_data() was called with an argument that is deprecated" Source: WordPress 6.9 Documentation, WordPress Support Forum Why It Happens: WordPress 6.9 (Dec 2, 2025) deprecated WP_Dependencies object methods Prevention: Test plugins with WP_DEBUG enabled on WordPress 6.9, replace deprecated methods
Affected Plugins (confirmed):
  • WooCommerce (fixed in 10.4.2)
  • Yoast SEO (fixed in 26.6)
  • Elementor (requires 3.24+)
Breaking Changes: WordPress 6.9 removed or modified several deprecated functions that older themes and plugins relied on, breaking custom menu walkers, classic widgets, media modals, and customizer features.
Action Required:
  • Test plugins with WP_DEBUG enabled on WordPress 6.9
  • Replace deprecated WP_Dependencies methods
  • Check for deprecation notices in debug.log
  • While top 1,000 plugins patched within hours, unmaintained plugins often lag behind

Issue #28: Translation Loading Changes in WordPress 6.7

Error: Translations don't load or debug notices appear Source: WooCommerce Developer Blog, WordPress 6.7 Field Guide Why It Happens: WordPress 6.7+ changed when/how translations load Prevention: Load translations after 'init' priority 10, ensure text domain matches plugin slug
// ❌ WRONG - Loading too early
add_action( 'init', 'load_plugin_textdomain' );

// ✅ CORRECT - Load after 'init' priority 10
add_action( 'init', 'load_plugin_textdomain', 11 );

// Ensure text domain matches plugin slug EXACTLY
// Plugin header: Text Domain: my-plugin
__( 'Text', 'my-plugin' );  // Must match exactly
Action Required:
  • Review when load_plugin_textdomain() is called
  • Ensure text domain matches plugin slug exactly
  • Test with WP_DEBUG enabled

Issue #29: wpdb::prepare() Missing Placeholders Error

Error: "The query argument of wpdb::prepare() must have a placeholder" Source: WordPress $wpdb Documentation, SitePoint: Working with Databases Why It Happens: Using prepare() without any placeholders Prevention: Don't use prepare() if no dynamic data
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts}" );
// Error: The query argument of wpdb::prepare() must have a placeholder

// ✅ CORRECT - Don't use prepare() if no dynamic data
$wpdb->get_results( "SELECT * FROM {$wpdb->posts}" );

// ✅ CORRECT - Use prepare() for dynamic data
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE ID = %d",
    $post_id
) );
Additional wpdb::prepare() Mistakes:
  1. Percentage Sign Handling:
// ❌ WRONG
$wpdb->prepare( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%test%'" );

// ✅ CORRECT
$search = '%' . $wpdb->esc_like( $term ) . '%';
$wpdb->get_results( $wpdb->prepare(
    "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE %s",
    $search
) );
`2.  **Mixing Argument Formats**:`
// ❌ WRONG - Can't mix individual args and array
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, array( $name ) );

// ✅ CORRECT - Pick one format
$wpdb->prepare( "... WHERE id = %d AND name = %s", $id, $name );
// OR
$wpdb->prepare( "... WHERE id = %d AND name = %s", array( $id, $name ) );

Plugin Architecture Patterns

Simple (Functions Only)

Small plugins (<5 functions):
function mypl_init() { /* code */ }
add_action( 'init', 'mypl_init' );

OOP (Singleton)

Medium plugins:
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() {
        add_action( 'init', array( $this, 'init' ) );
    }
}
MyPL_Plugin::get_instance();

PSR-4 (Modern, Recommended 2025+)

Large/team plugins:
my-plugin/
├── my-plugin.php
├── composer.json → "psr-4": { "MyPlugin\\": "src/" }
└── src/Admin.php

// my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
new Admin();

Common Patterns

Custom Post Types (CRITICAL: Flush rewrite rules on activation, show_in_rest for block editor):
// show_in_rest => true REQUIRED for Gutenberg block editor
register_post_type( 'book', array(
    'public' => true,
    'show_in_rest' => true,  // Without this, block editor won't work!
    'supports' => array( 'editor', 'title' ),
) );
register_activation_hook( __FILE__, function() {
    mypl_register_cpt();
    flush_rewrite_rules();  // NEVER call on every page load
} );
`**Custom Taxonomies**:`
register_taxonomy( 'genre', 'book', array( 'hierarchical' => true, 'show_in_rest' => true ) );
`**Meta Boxes**:`
add_meta_box( 'book_details', 'Book Details', 'mypl_meta_box_html', 'book' );
// Save: Check nonce, DOING_AUTOSAVE, current_user_can('edit_post')
update_post_meta( $post_id, '_book_isbn', sanitize_text_field( $_POST['book_isbn'] ) );
`**Settings API**:`
register_setting( 'mypl_options', 'mypl_api_key', array( 'sanitize_callback' => 'sanitize_text_field' ) );
add_settings_section( 'mypl_section', 'API Settings', 'callback', 'my-plugin' );
add_settings_field( 'mypl_api_key', 'API Key', 'field_callback', 'my-plugin', 'mypl_section' );
`**REST API** (10x faster than admin-ajax.php):`
register_rest_route( 'myplugin/v1', '/data', array(
    'methods'             => 'POST',
    'callback'            => 'mypl_rest_callback',
    'permission_callback' => fn() => current_user_can( 'edit_posts' ),
) );
`**AJAX** (Legacy, use REST API for new projects):`
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
wp_send_json_success( array( 'message' => 'Success' ) );
`**Custom Tables**:`
global $wpdb;
$sql = "CREATE TABLE {$wpdb->prefix}mypl_data (id bigint AUTO_INCREMENT PRIMARY KEY, ...)";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
`**Transients** (Caching):`
$data = get_transient( 'mypl_data' );
if ( false === $data ) {
    $data = expensive_operation();
    set_transient( 'mypl_data', $data, 12 * HOUR_IN_SECONDS );
}

Bundled Resources

Templates: plugin-simple/, plugin-oop/, plugin-psr4/, examples/meta-box.php, examples/settings-page.php, examples/custom-post-type.php, examples/rest-endpoint.php, examples/ajax-handler.php
Scripts: scaffold-plugin.sh, check-security.sh, validate-headers.sh
References: security-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.md

Advanced Topics

i18n (Internationalization):
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
__( 'Text', 'my-plugin' );  // Return translated
_e( 'Text', 'my-plugin' );  // Echo translated
esc_html__( 'Text', 'my-plugin' );  // Translate + escape
`**WP-CLI**:`
if ( defined( 'WP_CLI' ) && WP_CLI ) {
    WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
`**Cron Events**:`
register_activation_hook( __FILE__, fn() => wp_schedule_event( time(), 'daily', 'mypl_daily_task' ) );
register_deactivation_hook( __FILE__, fn() => wp_clear_scheduled_hook( 'mypl_daily_task' ) );
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
`**Plugin Dependencies**:`
if ( ! class_exists( 'WooCommerce' ) ) {
    deactivate_plugins( plugin_basename( __FILE__ ) );
    add_action( 'admin_notices', fn() => echo '<div><p>Requires WooCommerce</p></div>' );
}

Distribution & Auto-Updates

GitHub Auto-Updates (Plugin Update Checker by YahnisElsts):
// 1. Install: 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'
);
$updateChecker->getVcsApi()->enableReleaseAssets();  // Use GitHub Releases

// Private repos: Define token in wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
    $updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
`**Deployment**:`
git tag 1.0.1 && git push origin main && git push origin 1.0.1
# Create GitHub Release with ZIP (exclude .git, tests)
Alternatives: Git Updater (no coding), Custom Update Server (full control), Freemius (commercial)
Security: Use HTTPS, never hardcode tokens, validate licenses, rate limit update checks
CRITICAL: ZIP must contain plugin folder: plugin.zip/my-plugin/my-plugin.php
Resources: See references/github-auto-updates.md, examples/github-updater.php

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

Fatal Error: Enable WP_DEBUG, check wp-content/debug.log, verify prefixed names, check dependencies
404 on CPT: Flush rewrite rules via Settings → Permalinks → Save
Nonce Fails: Check nonce name/action match, verify not expired (24h default)
AJAX Returns 0/-1: Verify action name matches wp_ajax_{action}, check nonce sent/verified
HTML Stripped: Use wp_kses_post() not sanitize_text_field() for safe HTML
Query Fails: Use $wpdb->prepare(), check $wpdb->prefix, verify 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

Last verified: 2026-01-21 | Skill version: 2.0.0 | Changes: Added 9 new issues from WordPress 6.7-6.9 research (bcrypt migration, WP_Dependencies deprecation, translation loading, REST API CVEs 2025-2026, wpdb::prepare() edge cases, nonce limitations, hook gotchas, CPT URL conflicts). Updated from 20 to 29 documented errors prevented. Error count: 20 → 29. Version: 1.x → 2.0.0 (major version due to significant WordPress version-specific content additions).