(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
**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+)
<?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 ) );
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 */ }
manage_options (Admin), edit_posts (Editor/Author), read (Subscriber)// 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 ) );
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)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$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'] ) );
// VULNERABLE echo $_POST['name']; echo '<div>'; // SECURE echo esc_html( $_POST['name'] ); echo '<div>';
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'] ) );
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 }
// Add to top of EVERY PHP file if ( ! defined( 'ABSPATH' ) ) { exit; }
// CAUSES CONFLICTS function init() {} class Settings {} add_option( 'api_key', $value ); // SAFE function mypl_init() {} class MyPL_Settings {} add_option( 'mypl_api_key', $value );
// ✅ 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 }
// 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_%'" );
// 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 ); } } );
// VULNERABLE update_option( 'mypl_setting', $_POST['value'] ); // SECURE update_option( 'mypl_setting', sanitize_text_field( $_POST['value'] ) );
// WRONG $search = '%' . $term . '%'; // CORRECT $search = '%' . $wpdb->esc_like( $term ) . '%'; $results = $wpdb->get_results( $wpdb->prepare( "... WHERE title LIKE %s", $search ) );
// DANGEROUS extract( $_POST ); // Now $any_array_key becomes a variable // SAFE $name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
permission_callback specified, or missing show_in_index => false for sensitive endpoints Prevention: Always add permission_callback AND hide sensitive endpoints from REST indexshow_in_index => false, exposed bearer token in /wp-json/ index, full admin privileges granted to unauthenticated attackers// ❌ 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/ ) );
// 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
// 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
// In wp-config.php (development only) define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); define( 'WP_DEBUG_DISPLAY', false );
// Plugin header // Text Domain: my-plugin // In code - MUST MATCH EXACTLY __( 'Text', 'my-plugin' ); _e( 'Text', 'my-plugin' );
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 } );
add_action( 'save_post', function( $post_id ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; } // Safe to save meta } );
// 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' ); }, ) ); } );
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'), ) );
'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.// ❌ 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 ) );
$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
// ❌ 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'] ) ); }
// ❌ 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
do_action( 'mypl_data_processed' ) not do_action( 'data_processed' )// ❌ 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/
$wp$2y$ (SHA-384 pre-hashed bcrypt)// ✅ 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) }
// ❌ 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
// ❌ 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 ) );
// ❌ 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 ) );
function mypl_init() { /* code */ } add_action( 'init', 'mypl_init' );
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();
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();
// 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 ); }
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.phpscaffold-plugin.sh, check-security.sh, validate-headers.shsecurity-checklist.md, hooks-reference.md, sanitization-guide.md, wpdb-patterns.md, common-errors.mdload_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>' ); }
// 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)
plugin.zip/my-plugin/my-plugin.phpreferences/github-auto-updates.md, examples/github-updater.phpwp_ajax_{action}, check nonce sent/verifiedwp_kses_post() not sanitize_text_field() for safe HTML$wpdb->prepare(), check $wpdb->prefix, verify syntaxreferences/common-errors.md for extended troubleshooting