WordPress Plugin Development Review Skill
Overview
Systematic plugin architecture review for WordPress plugins targeting WordPress 6.x+. Core principle: WordPress plugins extend functionality through a standardized API ecosystem—lifecycle hooks (activation/deactivation/uninstall), Settings API, hooks system (actions/filters), custom post types/taxonomies, and internationalization. Review validates plugin architecture, naming conventions, WordPress.org compliance standards, and cross-references wp-security-review for security-specific patterns (nonces, capabilities, sanitization). Report findings grouped by file with line numbers, severity labels (CRITICAL/WARNING/INFO), and BAD/GOOD code pairs showing proper implementations.
When to Use
Use when:
- Plugin code architecture review
- Pre-submission check for WordPress.org Plugin Directory
- Auditing plugin lifecycle hooks (activation/deactivation/uninstall)
- Reviewing custom post type or taxonomy registration
- Validating Settings API implementation
- Checking hooks system usage (actions/filters, priority, removal)
- Internationalization (i18n/l10n) audit
- WordPress Plugin Check (PCP) compliance verification
- Prefixing and namespace collision detection
Don't use for:
- Theme development (use wp-theme-development when available)
- Gutenberg block development (use wp-block-development when available)
- WooCommerce extension development (use wp-woocommerce-dev when available)
- Security-only audits (use wp-security-review for comprehensive security analysis)
- Performance-only audits (use wp-performance-review)
Code Review Workflow
Follow this six-step workflow for systematic plugin reviews:
Identify plugin type and context
- WordPress.org submission → Apply strictest standards (Plugin Check compliance)
- Private/enterprise plugin → More flexibility on naming, no readme.txt required
- Must-use plugin (mu-plugin) → Different loading (no activation hooks needed)
- Drop-in plugin → Special files (object-cache.php, db.php) with different patterns
Check plugin header completeness and metadata
- Required:
Plugin Namefield present - Recommended: Version, Description, Author, Text Domain, Requires at least, Requires PHP, License
- Verify text domain matches plugin slug exactly (lowercase, dashes not underscores)
- Required:
Scan for CRITICAL patterns (breaks functionality or causes WordPress.org rejection)
- Missing
defined( 'ABSPATH' ) || exit;check in PHP files - Unprefixed functions/classes in global scope (namespace collision risk)
- Missing activation/deactivation hooks for setup/cleanup
- Text domain mismatch between header and i18n function calls
- No uninstall cleanup (uninstall.php or register_uninstall_hook missing)
flush_rewrite_rules()called oninithook (expensive operation on every page load)- Direct database table creation with
$wpdb->query()instead ofdbDelta() - Filter callbacks missing return statement
- REST API routes without
permission_callback(WordPress 5.5+ requirement) - Post type keys exceeding 20 characters
- Taxonomy names exceeding 32 characters
- Missing
Check WARNING patterns (non-standard but functional)
- Missing internationalization (i18n) on user-facing strings
__()or_e()calls without text domain parameter- Hardcoded WordPress paths (
/wp-content/plugins/) - Missing
sanitize_callbackinregister_setting() - Missing
show_in_restfor Gutenberg compatibility - Admin code loading on frontend (missing
is_admin()check)
Note INFO improvements (best practices and optimizations)
- Could use PHP namespaces instead of prefixing
- Missing version requirements in plugin header (
Requires at least,Requires PHP) - Could add
show_in_restfor Block Editor support - Admin assets enqueueing globally (should conditionally load)
- Missing
Domain Pathin header
Apply context-aware severity and report
- WordPress.org context: Strictest enforcement (Plugin Check failures = CRITICAL)
- Private plugin context: More flexible (prefixing still important, readme.txt optional)
- Must-use plugin: Adjust expectations (activation hooks not applicable)
- Report using output format below
- If security concerns found, add note: "Security issues detected. Run
/wp-sec-reviewfor comprehensive security analysis."
File-Type Specific Checks
Main Plugin File (e.g., my-plugin.php)
Plugin headers (PLG-02):
- CRITICAL: Missing
Plugin Namefield → WordPress won't recognize plugin - WARNING: Missing
Text Domain→ Breaks internationalization - WARNING: Text domain doesn't match plugin slug → WordPress.org rejection
- INFO: Missing
Requires at leastorRequires PHP→ Can't enforce version requirements
ABSPATH check (PLG-03):
- CRITICAL: Missing
defined( 'ABSPATH' ) || exit;at top → Direct file access possible - Pattern to detect: PHP file without ABSPATH check in first 10 lines
Prefixing (PLG-04):
- CRITICAL: Unprefixed function in global scope → Namespace collision with other plugins
- CRITICAL: Unprefixed class in global scope → Fatal error if another plugin uses same name
- CRITICAL: Unprefixed global variables → Variable collision risk
- WARNING: Options/transients without prefix → Database key collision
- Pattern:
function [a-z_]+\(without plugin prefix (4-5 char minimum) - Alternative: PHP namespaces eliminate need for prefixing
Activation/deactivation hooks (PLG-04):
- WARNING: Missing
register_activation_hook()but plugin has setup needs → No way to initialize - WARNING: Missing
register_deactivation_hook()but plugin has temp data → No cleanup on deactivation - Pattern: Look for
register_activation_hook( __FILE__, 'callback' )
Uninstall File (uninstall.php)
WP_UNINSTALL_PLUGIN constant check (PLG-05):
- CRITICAL:
uninstall.phpwithoutWP_UNINSTALL_PLUGINcheck → Can be executed directly - Pattern:
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) { exit; }
Cleanup completeness (PLG-05):
- WARNING: No option cleanup (
delete_option()) → Leaves plugin data in database - WARNING: No transient cleanup (
delete_transient()) → Database bloat - WARNING: No custom table cleanup (
DROP TABLE) → Orphaned tables - WARNING: No user meta cleanup (
delete_metadata( 'user', ... )) → User data remains - Context: Only applies if plugin creates these data types
Custom Post Type Files
register_post_type() usage (PLG-06):
- CRITICAL: Post type key exceeds 20 characters → Truncation issues, permalink problems
- CRITICAL: Post type key not prefixed → Query var conflict with WordPress core or other plugins
- WARNING: Missing
show_in_rest => true→ Not accessible in Block Editor - WARNING:
flush_rewrite_rules()called oninit→ Performance issue (expensive DB write on every page load) - Pattern:
register_post_type( 'key', array( ... ) )hooked toinit - Best practice: Post type key max 20 chars, lowercase alphanumeric + dash/underscore
Rewrite flush timing (PLG-06):
- CRITICAL:
flush_rewrite_rules()oninithook → DB write on every page load - Correct pattern: Only call in activation hook after registering post type
Taxonomy Files
register_taxonomy() usage (PLG-07):
- CRITICAL: Taxonomy name exceeds 32 characters → Database error
- CRITICAL: Taxonomy name not prefixed → Collision with core taxonomies or other plugins
- WARNING: Missing
show_in_rest => true→ Not accessible in Block Editor - Pattern:
register_taxonomy( 'name', 'post_type', array( ... ) )hooked toinit - Best practice: Taxonomy name max 32 chars, lowercase letters + underscore only
Settings API Files
register_setting() usage (PLG-08):
- CRITICAL:
add_settings_field()without correspondingregister_setting()→ Field won't save - WARNING:
register_setting()withoutsanitize_callback→ Unsanitized data stored - WARNING:
register_setting()withouttypeparameter → Type inference unreliable - Pattern:
register_setting( 'option_group', 'option_name', array( ... ) )onadmin_init
Settings API workflow (PLG-09):
- WARNING:
add_settings_section()page slug doesn't matchdo_settings_sections()call → Section won't render - WARNING:
add_settings_field()section ID doesn't matchadd_settings_section()ID → Field won't render - WARNING: Form missing
settings_fields()call → No nonce protection, options won't save - WARNING: Form missing
do_settings_sections()call → Fields won't render - Pattern: Complete workflow requires
register_setting()→add_settings_section()→add_settings_field()→ form HTML withsettings_fields()anddo_settings_sections()
Hooks Usage Files
add_action/add_filter patterns (PLG-10):
- WARNING: Hook priority not specified when order matters → May run in wrong sequence
- WARNING:
accepted_argsnot specified when callback needs multiple parameters → Parameters truncated - INFO: Could specify priority explicitly for clarity (even when using default 10)
- Pattern:
add_action( 'hook_name', 'callback', $priority, $accepted_args )
Filter return requirement (PLG-10):
- CRITICAL: Filter callback doesn't return value → Breaks filter chain, causes fatal error
- Pattern:
add_filter( 'hook', 'callback' )where callback has no return statement - Check: Every filter callback MUST return a value
Hook removal (PLG-10):
- WARNING:
remove_action()orremove_filter()params don't match registration → Won't remove hook - Pattern: Priority and accepted_args must EXACTLY match the original
add_action()/add_filter()call - Example:
add_action( 'init', 'callback', 15 )requiresremove_action( 'init', 'callback', 15 )
Custom hooks for extensibility (PLG-10):
- INFO: Plugin could provide custom hooks with
do_action()orapply_filters()for extensibility - Pattern:
do_action( 'myplugin_after_save', $data )orapply_filters( 'myplugin_modify_output', $output )
Internationalization (i18n) Files
Text domain matching (PLG-11):
- CRITICAL: Text domain in plugin header doesn't match text domain in i18n calls → Translation loading fails
- CRITICAL: Text domain uses underscores instead of dashes → WordPress.org rejection
- CRITICAL: Text domain doesn't match plugin slug → Plugin Check fails
- Pattern:
Text Domain: my-pluginmust match plugin folder/file name exactly
i18n function usage (PLG-11):
- WARNING:
__()or_e()without text domain parameter → Falls back to core translations (incorrect) - WARNING: Variables embedded in translatable strings → Breaks translation extraction
- Pattern: ALWAYS
__( 'Text', 'text-domain' )never__( 'Text' ) - Pattern: Use
sprintf( __( 'Text %s', 'domain' ), $var )not__( "Text $var", 'domain' )
Pluralization (PLG-11):
- WARNING: Plural forms handled with if/else instead of
_n()→ Breaks languages with complex plural rules - Pattern: Use
_n( 'singular', 'plural', $count, 'domain' )for count-dependent strings
Context-aware translation (PLG-11):
- INFO: Could use
_x()for disambiguation when same English word has different translations - Pattern:
_x( 'Post', 'noun', 'domain' )vs_x( 'Post', 'verb', 'domain' )
Escaped translation variants (PLG-11):
- INFO: Use
esc_html__()oresc_attr__()for combined translation + escaping - Pattern:
echo esc_html__( 'Text', 'domain' )instead ofecho esc_html( __( 'Text', 'domain' ) )
Translation loading (PLG-11):
- INFO:
load_plugin_textdomain()optional for WordPress 4.6+ (auto-loads from translate.wordpress.org) - Pattern: Hook to
initif called:add_action( 'init', 'load_textdomain_callback' )
REST API Endpoints (PLG-13, surface-level)
register_rest_route() usage:
- CRITICAL: Missing
permission_callback→ WordPress 5.5+ error, endpoint blocked - CRITICAL:
permission_callback => '__return_true'on write operations → Unauthenticated access allowed - Pattern:
register_rest_route( 'namespace/v1', '/route', array( ... ) )onrest_api_inithook - Note:
__return_trueis CORRECT for public read-only endpoints
Namespace and versioning:
- WARNING: Missing version in namespace → No API evolution path
- Pattern: Use
myplugin/v1not justmyplugin
Parameter validation:
- WARNING: Missing
sanitize_callbackin args → Unsanitized input - WARNING: Missing
validate_callbackin args → Invalid input accepted - For security depth: See wp-security-review skill
AJAX Handlers (PLG-14, surface-level)
Hook registration:
- WARNING: Using
admin-ajax.phpfor high-frequency calls → Consider REST API for better performance - Pattern:
add_action( 'wp_ajax_action_name', 'callback' )for authenticated - Pattern:
add_action( 'wp_ajax_nopriv_action_name', 'callback' )for public
Security checks:
- Brief reminder: AJAX handlers need nonce verification (
check_ajax_referer()) and capability checks - For security depth: See wp-security-review skill
Admin Menus (PLG-15, surface-level)
add_menu_page/add_submenu_page:
- WARNING: Missing capability parameter → No access control
- WARNING: Page callback doesn't verify
current_user_can()→ Capability param insufficient alone - Pattern:
add_menu_page( $title, $menu_title, 'manage_options', $slug, $callback )
Database Tables (PLG-16, surface-level)
Table creation:
- CRITICAL: Using
$wpdb->query( "CREATE TABLE..." )instead ofdbDelta()→ No upgrade path, charset issues - Pattern:
require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); dbDelta( $sql ); - Pattern:
$wpdb->prefixfor table names (not hardcodedwp_) - Pattern:
$wpdb->get_charset_collate()for charset/collation
dbDelta SQL formatting requirements:
- WARNING: SQL not formatted for dbDelta → Table creation fails silently
- Requirements: Two spaces after PRIMARY KEY, no spaces around =, each field on own line
Cron Jobs (PLG-17, surface-level)
wp_schedule_event() usage:
- WARNING:
wp_schedule_event()without checkingwp_next_scheduled()→ Duplicate schedules - WARNING: Scheduled event not cleared on deactivation → Cron job continues after plugin disabled
- Pattern: Check
if ( ! wp_next_scheduled( 'hook' ) )beforewp_schedule_event() - Pattern:
wp_clear_scheduled_hook( 'hook' )in deactivation hook
Transients (PLG-18, surface-level)
set_transient() usage:
- WARNING: Transient keys not prefixed → Key collision with other plugins
- WARNING: Transients without expiration → Behaves like persistent option
- Pattern:
set_transient( 'mypl_key', $value, HOUR_IN_SECONDS )
Search Patterns for Quick Detection (PLG-21)
Use these rg commands for quick plugin scanning. Organized by severity.
CRITICAL Patterns
# Main plugin file should contain "Plugin Name" header
# If this returns nothing, the plugin likely lacks a valid main header.
rg -n "Plugin Name:" . -g '*.php'
# PHP files missing ABSPATH check (direct file access possible)
rg -L --iglob '*.php' "defined\s*\(\s*['\"]ABSPATH['\"]\s*\)|defined\s*\(\s*'ABSPATH'\s*\)" .
# Unprefixed function declarations in global scope (namespace collision risk)
rg -n "^function\s+[a-z_][a-z0-9_]*\s*\(" . -g '*.php'
# Text domain mismatch (compare header vs i18n calls)
# First: inspect the main plugin file header for the expected text domain
rg -n "Text Domain:" . -g '*.php'
# Then: compare that slug with translation calls across the plugin
rg -n "__\(|_e\(|_n\(|_x\(" . -g '*.php'
# flush_rewrite_rules outside activation/deactivation (expensive operation on every page load)
rg -n "flush_rewrite_rules" . -g '*.php'
# Filter callbacks missing return statement (manual follow-up required)
rg -n "add_filter\s*\(" . -g '*.php'
# register_rest_route without permission_callback (manual per-route review)
rg -n "register_rest_route\s*\(" . -g '*.php'
WARNING Patterns
# Missing register_activation_hook (no setup mechanism)
rg -n "register_activation_hook\s*\(" . -g '*.php'
# Missing uninstall cleanup (no uninstall.php and no register_uninstall_hook)
test -f uninstall.php || rg -n "register_uninstall_hook\s*\(" . -g '*.php'
# i18n functions without obvious text domain parameter (manual confirmation required)
rg -n "__\(|_e\(" . -g '*.php'
# Hardcoded /wp-content/plugins/ paths (breaks on custom WordPress installations)
rg -n "/wp-content/plugins/" . -g '*.php'
# Direct CREATE TABLE with $wpdb->query (should use dbDelta)
rg -n "wpdb->query\s*\(.*CREATE TABLE" . -g '*.php'
# Post type key length check (max 20 characters)
rg -n "register_post_type\s*\(" . -g '*.php'
# Missing show_in_rest in register_post_type (manual per-registration review)
rg -n "register_post_type\s*\(" . -g '*.php'
INFO Patterns
# Missing "Requires at least" or "Requires PHP" in plugin header
rg -n "Requires at least:|Requires PHP:" . -g '*.php'
# Functions not using PHP namespaces (could modernize)
rg -n "^function\s+myprefix_" . -g '*.php'
# Admin code loading on frontend (manual context check)
rg -n "add_action\s*\(\s*['\"]admin_" . -g '*.php'
# Missing Domain Path in plugin header
rg -n "Domain Path:" . -g '*.php'
Platform Context (PLG-12)
WordPress plugins operate in different distribution contexts. Adjust review severity based on context.
WordPress.org Submission Context
Strictest standards apply. Plugin Check (PCP) tool enforces these requirements:
Plugin Check (PCP) compliance:
- Proper plugin headers (Plugin Name required, Text Domain recommended)
- Text domain must match plugin slug exactly (lowercase, dashes not underscores)
- All PHP files must have ABSPATH check (
defined( 'ABSPATH' ) || exit;) - All i18n functions must include text domain parameter
- No deprecated WordPress functions
- License compatibility (GPLv2+ required for WordPress.org)
- readme.txt with proper format (or readme.md)
- No hardcoded
/wp-content/paths (useWP_CONTENT_DIRor helper functions) - Proper prefixing on all global-scope functions/classes/options
How to verify: Run Plugin Check tool before submission:
# Via WP-CLI
wp plugin install plugin-check --activate
wp plugin check my-plugin.php
# Or via WordPress Admin
# Navigate to: Plugins > Add New > Search "Plugin Check"
# Then: Tools > Plugin Check > Select your plugin
WordPress.org context severity escalation:
- Missing text domain → CRITICAL (was WARNING)
- Text domain mismatch → CRITICAL (was WARNING)
- Missing ABSPATH check → CRITICAL (was WARNING)
- Deprecated functions → CRITICAL (was INFO)
Private/Enterprise Plugin Context
More flexibility. No WordPress.org submission requirements.
Relaxed requirements:
- readme.txt not required (internal documentation acceptable)
- Can use proprietary license (no GPL requirement)
- Naming conventions more flexible (though prefixing still important to avoid collisions)
- Can use custom update mechanisms (no WordPress.org update system)
Still important:
- Prefixing to avoid conflicts with other plugins
- Lifecycle hooks for proper setup/cleanup
- Security best practices (see wp-security-review)
Must-Use Plugins (mu-plugins) Context
Different loading behavior. Must-use plugins load automatically before regular plugins.
Key differences:
- Loaded alphabetically from
wp-content/mu-plugins/directory - Load before regular plugins
- Cannot be deactivated via admin UI
- No activation/deactivation hooks (they don't activate/deactivate, they just load)
- Can't be updated via WordPress admin (manual file replacement)
Review adjustments for mu-plugins:
- WARNING (not CRITICAL): Missing
register_activation_hook()→ Expected for mu-plugins - WARNING (not CRITICAL): Missing
register_deactivation_hook()→ Expected for mu-plugins - Still need: Proper prefixing, ABSPATH checks, i18n, security practices
Pattern to detect mu-plugin context:
// Check if plugin is loaded as must-use
if ( strpos( __FILE__, WPMU_PLUGIN_DIR ) !== false ) {
// Running as must-use plugin - skip activation hooks
}
Drop-In Plugins Context
Special files with specific names. Drop-ins replace WordPress core functionality.
Common drop-ins:
object-cache.php→ Object caching backend (Redis, Memcached)db.php→ Custom database classadvanced-cache.php→ Page caching systemdb-error.php→ Custom database error pagemaintenance.php→ Custom maintenance mode page
Review adjustments for drop-ins:
- Skip standard plugin header checks (drop-ins don't use plugin headers)
- Skip activation hook checks (drop-ins load automatically)
- Skip text domain checks (usually internal, not user-facing)
- Focus on: Compatibility with WordPress core expectations for that drop-in type
Pattern to detect drop-in:
// Drop-ins are in wp-content/ root, not plugins/
// Check filename against known drop-in names
$drop_ins = array( 'object-cache.php', 'db.php', 'advanced-cache.php', 'db-error.php', 'maintenance.php' );
Quick Reference: Plugin Architecture Patterns
Common plugin patterns organized by concern. All examples use WordPress PHP Coding Standards (spaces inside parentheses, array() not [], Yoda conditions).
Plugin Headers
Complete header block with all recommended fields:
❌ BAD: Minimal header
<?php
/**
* Plugin Name: My Plugin
*/
✅ GOOD: Complete header with all recommended fields
<?php
/**
* Plugin Name: My Awesome Plugin
* Plugin URI: https://example.com/my-awesome-plugin
* Description: Does amazing things with WordPress
* Version: 1.0.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: John Doe
* Author URI: https://example.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-awesome-plugin
* Domain Path: /languages
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly
}
Prefixing and Namespacing
Avoid global namespace pollution:
❌ BAD: No prefix (namespace collision risk)
<?php
function save_settings() {
update_option( 'plugin_settings', $_POST['data'] );
}
class Plugin_Settings {
// ...
}
add_action( 'admin_init', 'save_settings' );
✅ GOOD: Prefixed functions and classes
<?php
function mypl_save_settings() {
update_option( 'mypl_settings', $_POST['data'] );
}
class MyPL_Settings {
// ...
}
add_action( 'admin_init', 'mypl_save_settings' );
✅ BETTER: Use PHP namespaces
<?php
namespace MyPlugin\Admin;
function save_settings() {
update_option( 'mypl_settings', $_POST['data'] ); // Still prefix option names
}
class Settings {
// ...
}
add_action( 'admin_init', __NAMESPACE__ . '\save_settings' );
Activation/Deactivation/Uninstall Lifecycle
Proper plugin lifecycle management:
❌ BAD: No lifecycle hooks
<?php
// Plugin registers CPT on init but never flushes rewrites
add_action( 'init', 'mypl_register_cpt' );
function mypl_register_cpt() {
register_post_type( 'mypl_book', array( /* args */ ) );
}
✅ GOOD: Complete lifecycle with activation/deactivation/uninstall
<?php
// Main plugin file: my-plugin.php
// Activation hook
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_activate() {
// Set default options
add_option( 'mypl_version', '1.0.0' );
// Register CPT before flushing
mypl_register_cpt();
// Flush rewrite rules ONLY on activation
flush_rewrite_rules();
}
// Deactivation hook
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
function mypl_deactivate() {
// Clear transients
delete_transient( 'mypl_cache' );
// Flush rewrite rules
flush_rewrite_rules();
}
// Init hook for normal operation
add_action( 'init', 'mypl_register_cpt' );
function mypl_register_cpt() {
register_post_type( 'mypl_book', array( /* args */ ) );
}
// Uninstall file: uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// Delete all plugin data
delete_option( 'mypl_version' );
delete_option( 'mypl_settings' );
// Drop custom table
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}mypl_data" );
Custom Post Types and Taxonomies
Proper CPT/taxonomy registration with rewrite rule management:
❌ BAD: Flushes rewrites on every page load
<?php
add_action( 'init', 'mypl_register_cpt' );
function mypl_register_cpt() {
register_post_type( 'book', array( // Missing prefix, no show_in_rest
'public' => true,
) );
flush_rewrite_rules(); // CRITICAL: Runs on EVERY page load
}
✅ GOOD: Prefixed, Gutenberg-ready, flushes only on activation
<?php
add_action( 'init', 'mypl_register_cpt_and_taxonomy' );
function mypl_register_cpt_and_taxonomy() {
// Register custom post type (max 20 chars, prefixed)
register_post_type(
'mypl_book',
array(
'labels' => array(
'name' => __( 'Books', 'my-plugin' ),
'singular_name' => __( 'Book', 'my-plugin' ),
),
'public' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'books' ),
'supports' => array( 'title', 'editor', 'thumbnail' ),
'show_in_rest' => true, // Gutenberg support
)
);
// Register custom taxonomy (max 32 chars, prefixed)
register_taxonomy(
'mypl_genre',
'mypl_book',
array(
'labels' => array(
'name' => __( 'Genres', 'my-plugin' ),
'singular_name' => __( 'Genre', 'my-plugin' ),
),
'hierarchical' => true,
'public' => true,
'show_in_rest' => true, // Gutenberg support
)
);
}
// Flush rewrites ONLY on activation
register_activation_hook( __FILE__, 'mypl_activate' );
function mypl_activate() {
mypl_register_cpt_and_taxonomy(); // Register first
flush_rewrite_rules(); // Then flush (only once)
}
register_deactivation_hook( __FILE__, 'mypl_deactivate' );
function mypl_deactivate() {
flush_rewrite_rules(); // Clean up rewrite rules
}
Settings API
Complete Settings API workflow:
❌ BAD: add_settings_field without register_setting (won't save)
<?php
add_action( 'admin_init', 'mypl_settings_init' );
function mypl_settings_init() {
add_settings_section( 'mypl_section', 'Settings', null, 'mypl-settings' );
add_settings_field(
'mypl_api_key',
'API Key',
'mypl_api_key_callback',
'mypl-settings',
'mypl_section'
);
// Missing register_setting() - field won't save!
}
✅ GOOD: Complete Settings API workflow
<?php
add_action( 'admin_init', 'mypl_register_settings' );
function mypl_register_settings() {
// 1. Register setting (declares option name and sanitization)
register_setting(
'mypl_options_group', // Option group
'mypl_settings', // Option name
array(
'type' => 'array',
'sanitize_callback' => 'mypl_sanitize_settings',
'default' => array(
'api_key' => '',
'enabled' => false,
),
)
);
// 2. Add settings section
add_settings_section(
'mypl_main_section',
__( 'Main Settings', 'my-plugin' ),
'mypl_section_callback',
'mypl-settings'
);
// 3. Add settings fields
add_settings_field(
'mypl_api_key',
__( 'API Key', 'my-plugin' ),
'mypl_api_key_callback',
'mypl-settings',
'mypl_main_section'
);
add_settings_field(
'mypl_enabled',
__( 'Enable Feature', 'my-plugin' ),
'mypl_enabled_callback',
'mypl-settings',
'mypl_main_section'
);
}
function mypl_section_callback() {
echo '<p>' . esc_html__( 'Configure your plugin settings below.', 'my-plugin' ) . '</p>';
}
function mypl_api_key_callback() {
$options = get_option( 'mypl_settings' );
$value = isset( $options['api_key'] ) ? $options['api_key'] : '';
?>
<input type="text"
name="mypl_settings[api_key]"
value="<?php echo esc_attr( $value ); ?>"
class="regular-text">
<?php
}
function mypl_enabled_callback() {
$options = get_option( 'mypl_settings' );
$checked = isset( $options['enabled'] ) && $options['enabled'];
?>
<input type="checkbox"
name="mypl_settings[enabled]"
value="1"
<?php checked( $checked, true ); ?>>
<?php
}
function mypl_sanitize_settings( $input ) {
$sanitized = array();
if ( isset( $input['api_key'] ) ) {
$sanitized['api_key'] = sanitize_text_field( $input['api_key'] );
}
$sanitized['enabled'] = isset( $input['enabled'] ) && $input['enabled'] ? true : false;
return $sanitized;
}
// Settings page HTML
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
// Output nonce, action, option_page fields
settings_fields( 'mypl_options_group' );
// Output sections and fields
do_settings_sections( 'mypl-settings' );
submit_button();
?>
</form>
</div>
<?php
}
Hooks System
Actions and filters with priority management:
❌ BAD: Filter without return statement (fatal error)
<?php
add_filter( 'the_content', 'mypl_modify_content' );
function mypl_modify_content( $content ) {
if ( is_single() ) {
$content .= '<p>Footer text</p>';
}
// Missing return! Fatal error.
}
✅ GOOD: Complete hooks usage with priority and return
<?php
// Action with priority (runs before core at priority 10)
add_action( 'init', 'mypl_register_cpt', 9 );
function mypl_register_cpt() {
register_post_type( 'mypl_book', array( /* args */ ) );
}
// Filter with return (CRITICAL)
add_filter( 'the_content', 'mypl_modify_content', 10, 1 );
function mypl_modify_content( $content ) {
if ( is_single() ) {
$content .= '<p>Footer text</p>';
}
return $content; // ALWAYS return in filters
}
// Multiple parameters with accepted_args
add_filter( 'wp_mail', 'mypl_modify_email', 10, 1 );
function mypl_modify_email( $args ) {
$args['from'] = 'Custom <custom@example.com>';
return $args;
}
// Removing hooks (MUST match exact registration params)
add_action( 'wp_footer', 'mypl_footer_code', 15 );
// To remove: priority must match
remove_action( 'wp_footer', 'mypl_footer_code', 15 );
// Create custom hooks for extensibility
function mypl_process_data( $data ) {
// Allow other plugins to modify data
$data = apply_filters( 'mypl_before_process', $data );
// Process data
$result = process( $data );
// Allow other plugins to hook after processing
do_action( 'mypl_after_process', $result );
return $result;
}
Internationalization
Text domain matching and placeholder usage:
❌ BAD: Variable embedded in translatable string
<?php
$count = 5;
echo __( "You have $count messages", 'my-plugin' ); // Breaks translation extraction
// Missing text domain
echo __( 'Save Settings' );
// Text domain mismatch
// Plugin folder: my-awesome-plugin
echo __( 'Save', 'my_plugin' ); // Underscore instead of dash
✅ GOOD: Placeholders with sprintf, correct text domain
<?php
// Basic translation with text domain
echo __( 'Settings saved successfully', 'my-awesome-plugin' );
// Translation with echo
_e( 'Click here to continue', 'my-awesome-plugin' );
// Placeholders with sprintf (NOT embedded variables)
$count = 5;
echo sprintf(
__( 'You have %d messages', 'my-awesome-plugin' ),
$count
);
// Pluralization with _n()
echo sprintf(
_n(
'One post found',
'%d posts found',
$count,
'my-awesome-plugin'
),
number_format_i18n( $count )
);
// Context-aware translation with _x()
echo _x( 'Post', 'noun - blog post', 'my-awesome-plugin' );
echo _x( 'Post', 'verb - submit', 'my-awesome-plugin' );
// Escaped output + translation
echo '<h1>' . esc_html__( 'Welcome', 'my-awesome-plugin' ) . '</h1>';
echo '<input placeholder="' . esc_attr__( 'Enter your name', 'my-awesome-plugin' ) . '">';
// Load text domain on init (optional in WP 4.6+)
add_action( 'init', 'mypl_load_textdomain' );
function mypl_load_textdomain() {
load_plugin_textdomain(
'my-awesome-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
REST API Registration (surface-level)
❌ BAD: Missing permission_callback (WordPress 5.5+ error)
<?php
add_action( 'rest_api_init', 'mypl_register_routes' );
function mypl_register_routes() {
register_rest_route( 'mypl/v1', '/posts', array(
'methods' => 'GET',
'callback' => 'mypl_get_posts',
// Missing: 'permission_callback' (WordPress 5.5+ blocks this)
) );
}
✅ GOOD: Complete REST endpoint with permission_callback and validation
<?php
add_action( 'rest_api_init', 'mypl_register_routes' );
function mypl_register_routes() {
// Public read-only endpoint
register_rest_route(
'mypl/v1', // Namespace with version
'/posts',
array(
'methods' => 'GET',
'callback' => 'mypl_get_posts',
'permission_callback' => '__return_true', // Explicitly public (OK for read-only)
'args' => array(
'per_page' => array(
'default' => 10,
'sanitize_callback' => 'absint',
'validate_callback' => function( $param ) {
return is_numeric( $param ) && $param > 0 && $param <= 100;
},
),
),
)
);
// Protected write endpoint
register_rest_route(
'mypl/v1',
'/posts/(?P<id>\d+)',
array(
'methods' => 'DELETE',
'callback' => 'mypl_delete_post',
'permission_callback' => function() {
return current_user_can( 'delete_posts' );
},
'args' => array(
'id' => array(
'validate_callback' => function( $param ) {
return is_numeric( $param );
},
),
),
)
);
}
function mypl_get_posts( $request ) {
$per_page = $request->get_param( 'per_page' );
$posts = get_posts( array( 'posts_per_page' => $per_page ) );
return rest_ensure_response( $posts );
}
function mypl_delete_post( $request ) {
$post_id = $request->get_param( 'id' );
$result = wp_delete_post( $post_id, true );
if ( ! $result ) {
return new WP_Error(
'delete_failed',
__( 'Failed to delete post', 'my-plugin' ),
array( 'status' => 500 )
);
}
return rest_ensure_response( array( 'deleted' => true, 'id' => $post_id ) );
}
Note: For security depth on REST endpoints (nonce validation, capability checks, input sanitization), see wp-security-review skill.
AJAX Handlers (surface-level)
✅ GOOD: AJAX handler registration
<?php
// Authenticated users
add_action( 'wp_ajax_mypl_save_data', 'mypl_save_data_callback' );
// Public (non-authenticated)
add_action( 'wp_ajax_nopriv_mypl_save_data', 'mypl_save_data_callback' );
function mypl_save_data_callback() {
// Security checks (see wp-security-review for depth)
check_ajax_referer( 'mypl_nonce', 'nonce' );
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error( 'Insufficient permissions' );
}
$data = sanitize_text_field( $_POST['data'] );
// Process data
update_option( 'mypl_data', $data );
wp_send_json_success( 'Data saved' );
}
Conditional Loading (PLG-20)
Separate admin from public code:
❌ BAD: Admin code loading on every page
<?php
add_action( 'wp_enqueue_scripts', 'mypl_enqueue_admin_assets' );
function mypl_enqueue_admin_assets() {
// Admin assets loading on public pages (unnecessary)
wp_enqueue_script( 'mypl-admin', plugin_dir_url( __FILE__ ) . 'admin.js' );
}
✅ GOOD: Conditional admin loading
<?php
// Only load admin code in admin context
if ( is_admin() ) {
require_once plugin_dir_path( __FILE__ ) . 'admin/class-admin.php';
}
// Admin-specific assets
add_action( 'admin_enqueue_scripts', 'mypl_enqueue_admin_assets' );
function mypl_enqueue_admin_assets() {
wp_enqueue_script(
'mypl-admin',
plugin_dir_url( __FILE__ ) . 'admin/admin.js',
array( 'jquery' ),
'1.0.0',
true
);
}
// Public-facing assets
add_action( 'wp_enqueue_scripts', 'mypl_enqueue_public_assets' );
function mypl_enqueue_public_assets() {
wp_enqueue_script(
'mypl-public',
plugin_dir_url( __FILE__ ) . 'public/public.js',
array( 'jquery' ),
'1.0.0',
true
);
}
Function/Class Existence Checks (PLG-19)
Prevent fatal errors from conflicts:
✅ GOOD: Existence checks for functions and classes
<?php
// Function existence check
if ( ! function_exists( 'mypl_init' ) ) {
function mypl_init() {
// Plugin initialization
}
}
// Class existence check
if ( ! class_exists( 'MyPL_Plugin' ) ) {
class MyPL_Plugin {
// Plugin class
}
}
Severity Definitions (PLG-23)
| Severity | Definition | Examples |
|---|---|---|
| CRITICAL | Will cause WordPress.org rejection OR breaks plugin functionality OR causes fatal errors | Missing Plugin Name header, unprefixed global functions causing namespace collision, text domain mismatch, missing ABSPATH check, direct DB table creation without dbDelta, filter callback without return statement, post type key > 20 chars, taxonomy name > 32 chars, flush_rewrite_rules() on init hook, REST route without permission_callback (WP 5.5+) |
| WARNING | Non-standard patterns causing maintainability or compatibility issues, but plugin still functions | Missing internationalization (i18n), __() without text domain, hardcoded /wp-content/ paths, missing sanitize_callback in register_setting(), missing show_in_rest for Gutenberg support, wp_schedule_event() without checking for existing schedule, transients without expiration |
| INFO | Best practice improvements and modernization opportunities | Could use PHP namespaces instead of prefixing, missing version requirements in header (Requires at least, Requires PHP), admin code loading on frontend, missing Domain Path in header, could use _x() for context-aware translation |
Output Format (PLG-23)
Report findings grouped by FILE, with line numbers and severity labels. Use BAD/GOOD code pairs for each finding.
# WordPress Plugin Review: my-awesome-plugin
## FILE: my-awesome-plugin.php
### Line 15: CRITICAL - Missing ABSPATH check
Direct file access possible. Add ABSPATH check at top of all PHP files.
❌ BAD:
<?php
/**
* Plugin Name: My Plugin
*/
function mypl_init() { ... }
✅ GOOD:
<?php
/**
* Plugin Name: My Plugin
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
function mypl_init() { ... }
### Line 45: WARNING - Missing text domain in i18n call
Translation won't work. Always include text domain parameter.
❌ BAD:
echo __( 'Save Settings' );
✅ GOOD:
echo __( 'Save Settings', 'my-awesome-plugin' );
## FILE: includes/class-cpt.php
### Line 23: CRITICAL -
…(truncated)