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

1520 lines
40 KiB
Markdown

---
name: wordpress-plugin-core
description: |
Build secure WordPress plugins with core patterns for hooks, database interactions, Settings API, custom post types, REST API, and AJAX. Covers three architecture patterns (Simple, OOP, PSR-4) and the Security Trinity.
Use when creating plugins, implementing nonces/sanitization/escaping, working with $wpdb prepared statements, or troubleshooting SQL injection, XSS, CSRF vulnerabilities, or plugin activation errors.
license: MIT
---
# WordPress Plugin Development (Core)
**Status**: Production Ready
**Last Updated**: 2025-11-06
**Dependencies**: None (WordPress 5.9+, PHP 7.4+)
**Latest Versions**: WordPress 6.7+, PHP 8.0+ recommended
---
## Quick Start (10 Minutes)
### 1. Choose Your Plugin Structure
WordPress plugins can use three architecture patterns:
- **Simple** (functions only) - For small plugins with <5 functions
- **OOP** (Object-Oriented) - For medium plugins with related functionality
- **PSR-4** (Namespaced + Composer autoload) - For large/modern plugins
**Why this matters:**
- Simple plugins are easiest to start but don't scale well
- OOP provides organization without modern PHP features
- PSR-4 is the modern standard (2025) and most maintainable
### 2. Create Plugin Header
Every plugin MUST have a header comment in the main file:
```php
<?php
/**
* Plugin Name: My Awesome Plugin
* Plugin URI: https://example.com/my-plugin/
* Description: Brief description of what this plugin does.
* Version: 1.0.0
* Requires at least: 5.9
* Requires PHP: 7.4
* Author: Your Name
* Author URI: https://yoursite.com/
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-plugin
* Domain Path: /languages
*/
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
```
**CRITICAL:**
- Plugin Name is the ONLY required field
- Text Domain must match plugin slug exactly (for translations)
- Always add ABSPATH check to prevent direct file access
### 3. Implement The Security Foundation
Before writing ANY functionality, implement these 5 security essentials:
```php
// 1. Unique Prefix (4-5 chars minimum)
define( 'MYPL_VERSION', '1.0.0' );
function mypl_init() {
// Your code
}
add_action( 'init', 'mypl_init' );
// 2. ABSPATH Check (every PHP file)
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// 3. Nonces for Forms
<input type="hidden" name="mypl_nonce" value="<?php echo wp_create_nonce( 'mypl_action' ); ?>" />
// 4. Sanitize Input, Escape Output
$clean = sanitize_text_field( $_POST['input'] );
echo esc_html( $output );
// 5. Prepared Statements for Database
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}table WHERE id = %d",
$id
)
);
```
---
## The 5-Step Security Foundation
WordPress plugin security has THREE components that must ALL be present:
### Step 1: Use Unique Prefix for Everything
**Why**: Prevents naming conflicts with other plugins and WordPress core.
**Rules**:
- 4-5 characters minimum
- Apply to: functions, classes, constants, options, transients, meta keys, global variables
- Avoid: `wp_`, `__`, `_`, "WordPress"
```php
// GOOD
function mypl_function_name() {}
class MyPL_Class_Name {}
define( 'MYPL_CONSTANT', 'value' );
add_option( 'mypl_option', 'value' );
set_transient( 'mypl_cache', $data, HOUR_IN_SECONDS );
// BAD
function function_name() {} // No prefix, will conflict
class Settings {} // Too generic
```
### Step 2: Check Capabilities, Not Just Admin Status
**ERROR**: Using `is_admin()` for permission checks
```php
// WRONG - Anyone can access admin area URLs
if ( is_admin() ) {
// Delete user data - SECURITY HOLE
}
// CORRECT - Check user capability
if ( current_user_can( 'manage_options' ) ) {
// Delete user data - Now secure
}
```
**Common Capabilities**:
- `manage_options` - Administrator
- `edit_posts` - Editor/Author
- `publish_posts` - Author
- `edit_pages` - Editor
- `read` - Subscriber
### Step 3: The Security Trinity
**Input → Processing → Output** each require different functions:
```php
// SANITIZATION (Input) - Clean user data
$name = sanitize_text_field( $_POST['name'] );
$email = sanitize_email( $_POST['email'] );
$url = esc_url_raw( $_POST['url'] );
$html = wp_kses_post( $_POST['content'] ); // Allow safe HTML
$key = sanitize_key( $_POST['option'] );
$ids = array_map( 'absint', $_POST['ids'] ); // Array of integers
// VALIDATION (Logic) - Verify it meets requirements
if ( ! is_email( $email ) ) {
wp_die( 'Invalid email' );
}
// ESCAPING (Output) - Make safe for display
echo esc_html( $name );
echo '<a href="' . esc_url( $url ) . '">';
echo '<div class="' . esc_attr( $class ) . '">';
echo '<textarea>' . esc_textarea( $content ) . '</textarea>';
```
**Critical Rule**: Sanitize on INPUT, escape on OUTPUT. Never trust user data.
### Step 4: Nonces (CSRF Protection)
**What**: One-time tokens that prove requests came from your site.
**Form Pattern**:
```php
// Generate nonce in form
<form method="post">
<?php wp_nonce_field( 'mypl_action', 'mypl_nonce' ); ?>
<input type="text" name="data" />
<button type="submit">Submit</button>
</form>
// Verify nonce in handler
if ( ! isset( $_POST['mypl_nonce'] ) || ! wp_verify_nonce( $_POST['mypl_nonce'], 'mypl_action' ) ) {
wp_die( 'Security check failed' );
}
// Now safe to proceed
$data = sanitize_text_field( $_POST['data'] );
```
**AJAX Pattern**:
```javascript
// JavaScript
jQuery.ajax({
url: ajaxurl,
data: {
action: 'mypl_ajax_action',
nonce: mypl_ajax_object.nonce,
data: formData
}
});
```
```php
// PHP Handler
function mypl_ajax_handler() {
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
// Safe to proceed
wp_send_json_success( array( 'message' => 'Success' ) );
}
add_action( 'wp_ajax_mypl_ajax_action', 'mypl_ajax_handler' );
// Localize script with nonce
wp_localize_script( 'mypl-script', 'mypl_ajax_object', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mypl-ajax-nonce' ),
) );
```
### Step 5: Prepared Statements for Database
**CRITICAL**: Always use `$wpdb->prepare()` for queries with user input.
```php
global $wpdb;
// WRONG - SQL Injection vulnerability
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}table WHERE id = {$_GET['id']}" );
// CORRECT - Prepared statement
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}table WHERE id = %d",
$_GET['id']
)
);
```
**Placeholders**:
- `%s` - String
- `%d` - Integer
- `%f` - Float
**LIKE Queries** (Special Case):
```php
$search = '%' . $wpdb->esc_like( $term ) . '%';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}posts WHERE post_title LIKE %s",
$search
)
);
```
---
## Critical Rules
### Always Do
**Use unique prefix** (4-5 chars) for all global code (functions, classes, options, transients)
**Add ABSPATH check** to every PHP file: `if ( ! defined( 'ABSPATH' ) ) exit;`
**Check capabilities** (`current_user_can()`) not just `is_admin()`
**Verify nonces** for all forms and AJAX requests
**Use $wpdb->prepare()** for all database queries with user input
**Sanitize input** with `sanitize_*()` functions before saving
**Escape output** with `esc_*()` functions before displaying
**Flush rewrite rules** on activation when registering custom post types
**Use uninstall.php** for permanent cleanup (not deactivation hook)
**Follow WordPress Coding Standards** (tabs for indentation, Yoda conditions)
### Never Do
**Never use extract()** - Creates security vulnerabilities
**Never trust $_POST/$_GET** without sanitization
**Never concatenate user input into SQL** - Always use prepare()
**Never use `is_admin()` alone** for permission checks
**Never output unsanitized data** - Always escape
**Never use generic function/class names** - Always prefix
**Never use short PHP tags** `<?` or `<?=` - Use `<?php` only
**Never delete user data on deactivation** - Only on uninstall
**Never register uninstall hook repeatedly** - Only once on activation
**Never use `register_uninstall_hook()` in main flow** - Use uninstall.php instead
---
## Known Issues Prevention
This skill prevents **20** documented issues:
### Issue #1: SQL Injection
**Error**: Database compromised via unescaped user input
**Source**: https://patchstack.com/articles/sql-injection/ (15% of all vulnerabilities)
**Why It Happens**: Direct concatenation of user input into SQL queries
**Prevention**: Always use `$wpdb->prepare()` with placeholders
```php
// 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
```php
// VULNERABLE
echo $_POST['name'];
echo '<div class="' . $_POST['class'] . '">';
// SECURE
echo esc_html( $_POST['name'] );
echo '<div class="' . esc_attr( $_POST['class'] ) . '">';
```
### Issue #3: CSRF (Cross-Site Request Forgery)
**Error**: Unauthorized actions performed on behalf of users
**Source**: https://blog.nintechnet.com/25-wordpress-plugins-vulnerable-to-csrf-attacks/
**Why It Happens**: No verification that requests originated from your site
**Prevention**: Use nonces with `wp_nonce_field()` and `wp_verify_nonce()`
```php
// 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
```php
// 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
```php
// 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
```php
// CAUSES CONFLICTS
function init() {}
class Settings {}
add_option( 'api_key', $value );
// SAFE
function mypl_init() {}
class MyPL_Settings {}
add_option( 'mypl_api_key', $value );
```
### Issue #7: Rewrite Rules Not Flushed
**Error**: Custom post types return 404 errors
**Source**: WordPress Plugin Handbook
**Why It Happens**: Forgot to flush rewrite rules after registering CPT
**Prevention**: Flush on activation, clear on deactivation
```php
function mypl_activate() {
mypl_register_cpt();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
```
### Issue #8: Transients Not Cleaned
**Error**: Database accumulates expired transients
**Source**: WordPress Transients API Documentation
**Why It Happens**: No cleanup on uninstall
**Prevention**: Delete transients in uninstall.php
```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
```php
// 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
```php
// VULNERABLE
update_option( 'mypl_setting', $_POST['value'] );
// SECURE
update_option( 'mypl_setting', sanitize_text_field( $_POST['value'] ) );
```
### Issue #11: Incorrect LIKE Queries
**Error**: SQL syntax errors or injection vulnerabilities
**Source**: WordPress $wpdb Documentation
**Why It Happens**: LIKE wildcards not escaped properly
**Prevention**: Use `$wpdb->esc_like()`
```php
// 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
```php
// DANGEROUS
extract( $_POST );
// Now $any_array_key becomes a variable
// SAFE
$name = isset( $_POST['name'] ) ? sanitize_text_field( $_POST['name'] ) : '';
```
### Issue #13: Missing Permission Callback in REST API
**Error**: Endpoints accessible to everyone
**Source**: WordPress REST API Handbook
**Why It Happens**: No `permission_callback` specified
**Prevention**: Always add permission_callback
```php
// VULNERABLE
register_rest_route( 'myplugin/v1', '/data', array(
'callback' => 'my_callback',
) );
// SECURE
register_rest_route( 'myplugin/v1', '/data', array(
'callback' => 'my_callback',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
) );
```
### Issue #14: Uninstall Hook Registered Repeatedly
**Error**: Option written on every page load
**Source**: WordPress Plugin Handbook
**Why It Happens**: register_uninstall_hook() called in main flow
**Prevention**: Use uninstall.php file instead
```php
// 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
```php
// 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
```php
// 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
```php
// 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
```php
add_action( 'plugins_loaded', function() {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', function() {
echo '<div class="error"><p>My Plugin requires WooCommerce.</p></div>';
} );
return;
}
// Initialize plugin
} );
```
### Issue #19: Autosave Triggering Meta Save
**Error**: Meta saved multiple times, performance issues
**Source**: WordPress Post Meta
**Why It Happens**: No autosave check in save_post hook
**Prevention**: Check for DOING_AUTOSAVE constant
```php
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)
```php
// OLD: admin-ajax.php (still works but slower)
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
// NEW: REST API (10x faster, recommended)
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/endpoint', array(
'methods' => 'POST',
'callback' => 'mypl_rest_handler',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
) );
} );
```
---
## Plugin Architecture Patterns
### Pattern 1: Simple Plugin (Functions Only)
**When to use**: Small plugins with <5 functions, no complex state
```php
<?php
/**
* Plugin Name: Simple Plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function mypl_init() {
// Your code here
}
add_action( 'init', 'mypl_init' );
function mypl_admin_menu() {
add_options_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
'mypl_settings_page'
);
}
add_action( 'admin_menu', 'mypl_admin_menu' );
function mypl_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
</div>
<?php
}
```
### Pattern 2: OOP Plugin
**When to use**: Medium plugins with related functionality, need organization
```php
<?php
/**
* Plugin Name: OOP Plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class MyPL_Plugin {
private static $instance = null;
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->define_constants();
$this->init_hooks();
}
private function define_constants() {
define( 'MYPL_VERSION', '1.0.0' );
define( 'MYPL_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MYPL_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
}
private function init_hooks() {
add_action( 'init', array( $this, 'init' ) );
add_action( 'admin_menu', array( $this, 'admin_menu' ) );
}
public function init() {
// Initialization code
}
public function admin_menu() {
add_options_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
array( $this, 'settings_page' )
);
}
public function settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
</div>
<?php
}
}
// Initialize plugin
function mypl() {
return MyPL_Plugin::get_instance();
}
mypl();
```
### Pattern 3: PSR-4 Plugin (Modern, Recommended)
**When to use**: Large/modern plugins, team development, 2025+ best practice
**Directory Structure**:
```
my-plugin/
├── my-plugin.php # Main file
├── composer.json # Autoloading config
├── src/ # PSR-4 autoloaded classes
│ ├── Admin.php
│ ├── Frontend.php
│ └── Settings.php
├── languages/
└── uninstall.php
```
**composer.json**:
```json
{
"name": "my-vendor/my-plugin",
"autoload": {
"psr-4": {
"MyPlugin\\": "src/"
}
},
"require": {
"php": ">=7.4"
}
}
```
**my-plugin.php**:
```php
<?php
/**
* Plugin Name: PSR-4 Plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
use MyPlugin\Admin;
use MyPlugin\Frontend;
class MyPlugin {
private static $instance = null;
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->init();
}
private function init() {
new Admin();
new Frontend();
}
}
MyPlugin::get_instance();
```
**src/Admin.php**:
```php
<?php
namespace MyPlugin;
class Admin {
public function __construct() {
add_action( 'admin_menu', array( $this, 'add_menu' ) );
}
public function add_menu() {
add_options_page(
'My Plugin',
'My Plugin',
'manage_options',
'my-plugin',
array( $this, 'settings_page' )
);
}
public function settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
</div>
<?php
}
}
```
---
## Common Patterns
### Pattern 1: Custom Post Types
```php
function mypl_register_cpt() {
register_post_type( 'book', array(
'labels' => array(
'name' => 'Books',
'singular_name' => 'Book',
'add_new_item' => 'Add New Book',
),
'public' => true,
'has_archive' => true,
'show_in_rest' => true, // Gutenberg support
'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
'rewrite' => array( 'slug' => 'books' ),
'menu_icon' => 'dashicons-book',
) );
}
add_action( 'init', 'mypl_register_cpt' );
// CRITICAL: Flush rewrite rules on activation
function mypl_activate() {
mypl_register_cpt();
flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
```
### Pattern 2: Custom Taxonomies
```php
function mypl_register_taxonomy() {
register_taxonomy( 'genre', 'book', array(
'labels' => array(
'name' => 'Genres',
'singular_name' => 'Genre',
),
'hierarchical' => true, // Like categories
'show_in_rest' => true,
'rewrite' => array( 'slug' => 'genre' ),
) );
}
add_action( 'init', 'mypl_register_taxonomy' );
```
### Pattern 3: Meta Boxes
```php
function mypl_add_meta_box() {
add_meta_box(
'book_details',
'Book Details',
'mypl_meta_box_html',
'book',
'normal',
'high'
);
}
add_action( 'add_meta_boxes', 'mypl_add_meta_box' );
function mypl_meta_box_html( $post ) {
$isbn = get_post_meta( $post->ID, '_book_isbn', true );
wp_nonce_field( 'mypl_save_meta', 'mypl_meta_nonce' );
?>
<label for="book_isbn">ISBN:</label>
<input type="text" id="book_isbn" name="book_isbn" value="<?php echo esc_attr( $isbn ); ?>" />
<?php
}
function mypl_save_meta( $post_id ) {
// Security checks
if ( ! isset( $_POST['mypl_meta_nonce'] )
|| ! wp_verify_nonce( $_POST['mypl_meta_nonce'], 'mypl_save_meta' ) ) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Save data
if ( isset( $_POST['book_isbn'] ) ) {
update_post_meta(
$post_id,
'_book_isbn',
sanitize_text_field( $_POST['book_isbn'] )
);
}
}
add_action( 'save_post_book', 'mypl_save_meta' );
```
### Pattern 4: Settings API
```php
function mypl_add_menu() {
add_options_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin',
'mypl_settings_page'
);
}
add_action( 'admin_menu', 'mypl_add_menu' );
function mypl_register_settings() {
register_setting( 'mypl_options', 'mypl_api_key', array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
) );
add_settings_section(
'mypl_section',
'API Settings',
'mypl_section_callback',
'my-plugin'
);
add_settings_field(
'mypl_api_key',
'API Key',
'mypl_field_callback',
'my-plugin',
'mypl_section'
);
}
add_action( 'admin_init', 'mypl_register_settings' );
function mypl_section_callback() {
echo '<p>Configure your API settings.</p>';
}
function mypl_field_callback() {
$value = get_option( 'mypl_api_key' );
?>
<input type="text" name="mypl_api_key" value="<?php echo esc_attr( $value ); ?>" />
<?php
}
function mypl_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'mypl_options' );
do_settings_sections( 'my-plugin' );
submit_button();
?>
</form>
</div>
<?php
}
```
### Pattern 5: REST API Endpoints
```php
add_action( 'rest_api_init', function() {
register_rest_route( 'myplugin/v1', '/data', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'mypl_rest_callback',
'permission_callback' => function() {
return current_user_can( 'edit_posts' );
},
'args' => array(
'id' => array(
'required' => true,
'validate_callback' => function( $param ) {
return is_numeric( $param );
},
'sanitize_callback' => 'absint',
),
),
) );
} );
function mypl_rest_callback( $request ) {
$id = $request->get_param( 'id' );
// Process...
return new WP_REST_Response( array(
'success' => true,
'data' => $data,
), 200 );
}
```
### Pattern 6: AJAX Handlers (Legacy)
```php
// Enqueue script with localized data
function mypl_enqueue_ajax_script() {
wp_enqueue_script( 'mypl-ajax', plugins_url( 'js/ajax.js', __FILE__ ), array( 'jquery' ), '1.0', true );
wp_localize_script( 'mypl-ajax', 'mypl_ajax_object', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mypl-ajax-nonce' ),
) );
}
add_action( 'wp_enqueue_scripts', 'mypl_enqueue_ajax_script' );
// AJAX handler (logged-in users)
function mypl_ajax_handler() {
check_ajax_referer( 'mypl-ajax-nonce', 'nonce' );
$data = sanitize_text_field( $_POST['data'] );
// Process...
wp_send_json_success( array( 'message' => 'Success' ) );
}
add_action( 'wp_ajax_mypl_action', 'mypl_ajax_handler' );
// AJAX handler (logged-out users)
add_action( 'wp_ajax_nopriv_mypl_action', 'mypl_ajax_handler' );
```
**JavaScript (js/ajax.js)**:
```javascript
jQuery(document).ready(function($) {
$('#my-button').on('click', function() {
$.ajax({
url: mypl_ajax_object.ajaxurl,
type: 'POST',
data: {
action: 'mypl_action',
nonce: mypl_ajax_object.nonce,
data: 'value'
},
success: function(response) {
console.log(response.data.message);
}
});
});
});
```
### Pattern 7: Custom Database Tables
```php
function mypl_create_tables() {
global $wpdb;
$table_name = $wpdb->prefix . 'mypl_data';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
data text NOT NULL,
created datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
add_option( 'mypl_db_version', '1.0' );
}
// Create tables on activation
register_activation_hook( __FILE__, 'mypl_create_tables' );
```
### Pattern 8: Transients for Caching
```php
function mypl_get_expensive_data() {
// Try to get cached data
$data = get_transient( 'mypl_expensive_data' );
if ( false === $data ) {
// Not cached - regenerate
$data = perform_expensive_operation();
// Cache for 12 hours
set_transient( 'mypl_expensive_data', $data, 12 * HOUR_IN_SECONDS );
}
return $data;
}
// Clear cache when data changes
function mypl_clear_cache() {
delete_transient( 'mypl_expensive_data' );
}
add_action( 'save_post', 'mypl_clear_cache' );
```
---
## Using Bundled Resources
### Templates (templates/)
Use these production-ready templates to scaffold plugins quickly:
- `templates/plugin-simple/` - Simple plugin with functions
- `templates/plugin-oop/` - Object-oriented plugin structure
- `templates/plugin-psr4/` - Modern PSR-4 plugin with Composer
- `templates/examples/meta-box.php` - Meta box implementation
- `templates/examples/settings-page.php` - Settings API page
- `templates/examples/custom-post-type.php` - CPT registration
- `templates/examples/rest-endpoint.php` - REST API endpoint
- `templates/examples/ajax-handler.php` - AJAX implementation
**When Claude should use these**: When creating new plugins or implementing specific functionality patterns.
### Scripts (scripts/)
- `scripts/scaffold-plugin.sh` - Interactive plugin scaffolding
- `scripts/check-security.sh` - Security audit for common issues
- `scripts/validate-headers.sh` - Verify plugin headers
**Example Usage:**
```bash
# Scaffold new plugin
./scripts/scaffold-plugin.sh my-plugin simple
# Check for security issues
./scripts/check-security.sh my-plugin.php
# Validate plugin headers
./scripts/validate-headers.sh my-plugin.php
```
### References (references/)
Detailed documentation that Claude can load when needed:
- `references/security-checklist.md` - Complete security audit checklist
- `references/hooks-reference.md` - Common WordPress hooks and filters
- `references/sanitization-guide.md` - All sanitization/escaping functions
- `references/wpdb-patterns.md` - Database query patterns
- `references/common-errors.md` - Extended error prevention guide
**When Claude should load these**: When dealing with security issues, choosing the right hook, sanitizing specific data types, writing database queries, or debugging common errors.
---
## Advanced Topics
### Internationalization (i18n)
```php
// Load text domain
function mypl_load_textdomain() {
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'plugins_loaded', 'mypl_load_textdomain' );
// Translatable strings
__( 'Text', 'my-plugin' ); // Returns translated string
_e( 'Text', 'my-plugin' ); // Echoes translated string
_n( 'One item', '%d items', $count, 'my-plugin' ); // Plural forms
esc_html__( 'Text', 'my-plugin' ); // Translate and escape
esc_html_e( 'Text', 'my-plugin' ); // Translate, escape, and echo
```
### WP-CLI Commands
```php
if ( defined( 'WP_CLI' ) && WP_CLI ) {
class MyPL_CLI_Command {
/**
* Process data
*
* ## EXAMPLES
*
* wp mypl process --limit=100
*
* @param array $args
* @param array $assoc_args
*/
public function process( $args, $assoc_args ) {
$limit = isset( $assoc_args['limit'] ) ? absint( $assoc_args['limit'] ) : 10;
WP_CLI::line( "Processing $limit items..." );
// Process...
WP_CLI::success( 'Processing complete!' );
}
}
WP_CLI::add_command( 'mypl', 'MyPL_CLI_Command' );
}
```
### Scheduled Events (Cron)
```php
// Schedule event on activation
function mypl_activate() {
if ( ! wp_next_scheduled( 'mypl_daily_task' ) ) {
wp_schedule_event( time(), 'daily', 'mypl_daily_task' );
}
}
register_activation_hook( __FILE__, 'mypl_activate' );
// Clear event on deactivation
function mypl_deactivate() {
wp_clear_scheduled_hook( 'mypl_daily_task' );
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
// Hook to scheduled event
function mypl_do_daily_task() {
// Perform task
}
add_action( 'mypl_daily_task', 'mypl_do_daily_task' );
```
### Plugin Dependencies Check
```php
add_action( 'admin_init', function() {
// Check for WooCommerce
if ( ! class_exists( 'WooCommerce' ) ) {
deactivate_plugins( plugin_basename( __FILE__ ) );
add_action( 'admin_notices', function() {
echo '<div class="error"><p><strong>My Plugin</strong> requires WooCommerce to be installed and active.</p></div>';
} );
if ( isset( $_GET['activate'] ) ) {
unset( $_GET['activate'] );
}
}
} );
```
---
## Distribution & Auto-Updates
### Enabling GitHub Auto-Updates
Plugins hosted outside WordPress.org can still provide automatic updates using **Plugin Update Checker** by YahnisElsts. This is the recommended solution for most use cases.
**Quick Start:**
```php
// 1. Install library (git submodule or Composer)
git submodule add https://github.com/YahnisElsts/plugin-update-checker.git
// 2. Add to main plugin file
require plugin_dir_path( __FILE__ ) . 'plugin-update-checker/plugin-update-checker.php';
use YahnisElsts\PluginUpdateChecker\v5\PucFactory;
$updateChecker = PucFactory::buildUpdateChecker(
'https://github.com/yourusername/your-plugin/',
__FILE__,
'your-plugin-slug'
);
// Use GitHub Releases (recommended)
$updateChecker->getVcsApi()->enableReleaseAssets();
// For private repos, use token from wp-config.php
if ( defined( 'YOUR_PLUGIN_GITHUB_TOKEN' ) ) {
$updateChecker->setAuthentication( YOUR_PLUGIN_GITHUB_TOKEN );
}
```
**Deployment:**
```bash
# 1. Update version in plugin header
# 2. Commit and tag
git add my-plugin.php
git commit -m "Bump version to 1.0.1"
git tag 1.0.1
git push origin main
git push origin 1.0.1
# 3. Create GitHub Release (optional but recommended)
# - Upload pre-built ZIP file (exclude .git, tests, etc.)
# - Add release notes for users
```
**Key Features:**
✅ Works with GitHub, GitLab, BitBucket, or custom servers
✅ Supports public and private repositories
✅ Uses GitHub Releases or tags for versioning
✅ Secure HTTPS-based updates
✅ Optional license key integration
✅ Professional release notes and changelogs
✅ ~100KB library footprint
**Alternative Solutions:**
1. **Git Updater** (user-installable plugin, no coding required)
2. **Custom Update Server** (full control, requires hosting)
3. **Freemius** (commercial, includes licensing and payments)
**Comprehensive Resources:**
- **Complete Guide**: See `references/github-auto-updates.md` (21 pages, all approaches)
- **Implementation Examples**: See `examples/github-updater.php` (10 examples)
- **Security Best Practices**: Checksums, signing, token storage, rate limiting
- **Template Integration**: All 3 plugin templates include setup instructions
**Security Considerations:**
- ✅ Always use HTTPS for repository URLs
- ✅ Never hardcode authentication tokens (use wp-config.php)
- ✅ Implement license validation before offering updates
- ✅ Optional: Add checksums for file verification
- ✅ Rate limit update checks to avoid API throttling
- ✅ Clear cached update data after installation
**When to Use Each Approach:**
| Use Case | Recommended Solution |
|----------|---------------------|
| Open source, public repo | Plugin Update Checker |
| Private plugin, client work | Plugin Update Checker + private repo |
| Commercial plugin | Freemius or Custom Server |
| Multi-platform Git hosting | Git Updater |
| Custom licensing needs | Custom Update Server |
**ZIP Structure Requirement:**
```
plugin.zip
└── my-plugin/ ← Plugin folder MUST be inside ZIP
├── my-plugin.php
├── readme.txt
└── ...
```
Incorrect structure will cause WordPress to create a random folder name and break the plugin!
---
## Dependencies
**Required**:
- WordPress 5.9+ (recommend 6.7+)
- PHP 7.4+ (recommend 8.0+)
**Optional**:
- Composer 2.0+ - For PSR-4 autoloading
- WP-CLI 2.0+ - For command-line plugin management
- Query Monitor - For debugging and performance analysis
---
## Official Documentation
- **WordPress Plugin Handbook**: https://developer.wordpress.org/plugins/
- **WordPress Coding Standards**: https://developer.wordpress.org/coding-standards/
- **WordPress REST API**: https://developer.wordpress.org/rest-api/
- **WordPress Database Class ($wpdb)**: https://developer.wordpress.org/reference/classes/wpdb/
- **WordPress Security**: https://developer.wordpress.org/apis/security/
- **Settings API**: https://developer.wordpress.org/plugins/settings/settings-api/
- **Custom Post Types**: https://developer.wordpress.org/plugins/post-types/
- **Transients API**: https://developer.wordpress.org/apis/transients/
- **Context7 Library ID**: /websites/developer_wordpress
---
## Troubleshooting
### Problem: Plugin causes fatal error
**Solution**:
1. Enable WP_DEBUG in wp-config.php
2. Check error log at wp-content/debug.log
3. Verify all class/function names are prefixed
4. Check for missing dependencies
### Problem: 404 errors on custom post type pages
**Solution**: Flush rewrite rules
```php
// Temporarily add to wp-admin
flush_rewrite_rules();
// Remove after visiting wp-admin once
```
### Problem: Nonce verification always fails
**Solution**:
1. Check nonce name matches in field and verification
2. Verify using correct action name
3. Ensure nonce hasn't expired (24 hour default)
### Problem: AJAX returns 0 or -1
**Solution**:
1. Verify action name matches hook: `wp_ajax_{action}`
2. Check nonce is being sent and verified
3. Ensure handler function exists and is hooked correctly
### Problem: Sanitization stripping HTML
**Solution**: Use `wp_kses_post()` instead of `sanitize_text_field()` to allow safe HTML
### Problem: Database queries not working
**Solution**:
1. Always use `$wpdb->prepare()` for queries with variables
2. Check table name includes `$wpdb->prefix`
3. Verify column names and syntax
---
## Complete Setup Checklist
Use this checklist to verify your plugin:
- [ ] Plugin header complete with all fields
- [ ] ABSPATH check at top of every PHP file
- [ ] All functions/classes use unique prefix
- [ ] All forms have nonce verification
- [ ] All user input is sanitized
- [ ] All output is escaped
- [ ] All database queries use $wpdb->prepare()
- [ ] Capability checks (not just is_admin())
- [ ] Custom post types flush rewrite rules on activation
- [ ] Deactivation hook only clears temporary data
- [ ] uninstall.php handles permanent cleanup
- [ ] Text domain matches plugin slug
- [ ] Scripts/styles only load where needed
- [ ] WP_DEBUG enabled during development
- [ ] Tested with Query Monitor for performance
- [ ] No deprecated function warnings
- [ ] Works with latest WordPress version
---
**Questions? Issues?**
1. Check `references/common-errors.md` for extended troubleshooting
2. Verify all steps in the security foundation
3. Check official docs: https://developer.wordpress.org/plugins/
4. Enable WP_DEBUG and check debug.log
5. Use Query Monitor plugin to debug hooks and queries