Architecture and development guidelines for WordPress plugins published on wordpress.org: file structure, plugin header, lifecycle hooks, Settings API, admin UI, custom post types, custom database tables, internationalization, plugin dependencies, and wordpress.org submission requirements. Based on the official WordPress Plugin Developer Handbook and Plugin Review Team guidelines.
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Architecture and development guidelines for WordPress plugins published on wordpress.org: file structure, plugin header, lifecycle hooks, Settings API, admin UI, custom post types, custom database tables, internationalization, plugin dependencies, and wordpress.org submission requirements. Based on the official WordPress Plugin Developer Handbook and Plugin Review Team guidelines.
compatibility
WordPress 6.0+ / PHP 7.4+. Targets plugins for distribution on wordpress.org.
license
GPL-2.0-or-later
metadata
{"author":"fernando-tellado","version":"1.2"}
WordPress plugin development
When to use
Use this skill when:
Creating a new WordPress plugin from scratch
Preparing a plugin for submission to wordpress.org
Structuring plugin files and folders
Implementing activation, deactivation, or uninstall routines
Building admin settings pages with the Settings API
Registering custom post types or taxonomies
Creating custom database tables
Making a plugin translation-ready
Handling plugin dependencies (required plugins or PHP extensions)
Reviewing code before wordpress.org submission
Core development principles
The plugin development mantra
Use WordPress APIs, never reinvent the wheel
Prefix everything, conflict with nothing
Clean up after yourself on uninstall
Leave no trace when disabled
Key concepts
Prefix everything: All functions, classes, constants, and options must use a unique prefix to avoid conflicts
WordPress APIs first: Use WordPress functions over native PHP whenever an API exists
Lifecycle awareness: Know what runs on activation, deactivation, and uninstall — and keep them separate
Settings API: Never save options by hand; use the Settings API to register, validate, and store settings
GPL compatibility: All code and bundled libraries must be GPL-compatible for wordpress.org
No inline assets: Never print
Prefixing rules
All functions, classes, constants, hooks, options, post types, taxonomy slugs, and script/style handles must use a unique prefix of at least 4 characters. The Plugin Review Team rejects plugins with short or generic prefixes.
Element
Correct
Wrong
Function
ayudawp_get_settings()
wp_get_settings(), get_settings()
Class
AyudaWP_Settings
Settings, WP_Settings
Constant
AYUDAWP_VERSION
VERSION, MY_VERSION
Option
ayudawp_settings
settings, my_settings
Post type
ayudawp_event
event, my_event
Hook
ayudawp_after_save
after_save
Script handle
ayudawp-admin
admin-script
Do not use wp_, wordpress_, or wc_ as prefixes — these are reserved by WordPress core and WooCommerce.
Prefix consistency
A unique prefix of 4+ characters is not enough on its own: the same prefix must be used across every identifier in the plugin. The Plugin Review Team counts how many identifiers use the dominant prefix and flags any outlier — even if the outlier is itself a valid 4-character prefix.
// WRONG: 28 identifiers use ayudawp_*, one uses Aeuw_* → flagged.classAeuw_Promo_Banner{ ... }
functionayudawp_register_settings() { ... }
functionayudawp_render_form() { ... }
add_option( 'ayudawp_settings', ... );
// CORRECT: every identifier uses the same dominant prefix.classAyudawp_Promo_Banner{ ... }
functionayudawp_register_settings() { ... }
functionayudawp_render_form() { ... }
add_option( 'ayudawp_settings', ... );
Class file names should follow the same convention: class-ayudawp-promo-banner.php, not class-aeuw-promo-banner.php. The file slug must match the class name in lowercase, with dashes between words.
This applies to function names, class names, constants, options, transients, meta keys, post type slugs, taxonomy slugs, capability names, hook names, script/style handles, AJAX action names, and CSS class prefixes used inside the plugin's own markup.
Plugin file structure
A well-organized plugin is easier to review, maintain, and extend.
The main file is a bootstrap: it defines constants, checks requirements, and loads the rest.
<?php/**
* Plugin Name: My Plugin
* Plugin URI: https://example.com/my-plugin
* Description: A brief description of what the plugin does.
* Version: 1.0.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: Your Name
* Author URI: https://example.com
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-plugin
*/// Prevent direct file access.if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Plugin constants.define( 'MYPLUGIN_VERSION', '1.0.0' );
define( 'MYPLUGIN_FILE', __FILE__ );
define( 'MYPLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MYPLUGIN_BASENAME', plugin_basename( __FILE__ ) );
// Minimum requirements check.functionmyplugin_meets_requirements() {
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
returnfalse;
}
if ( version_compare( get_bloginfo( 'version' ), '6.0', '<' ) ) {
returnfalse;
}
returntrue;
}
if ( ! myplugin_meets_requirements() ) {
add_action( 'admin_notices', 'myplugin_requirements_notice' );
return;
}
functionmyplugin_requirements_notice() {
echo'<div class="notice notice-error"><p>' .
esc_html__( 'My Plugin requires PHP 7.4+ and WordPress 6.0+.', 'my-plugin' ) .
'</p></div>';
}
// Load the plugin.require_once MYPLUGIN_DIR . 'includes/class-my-plugin.php';
// Lifecycle hooks must be registered in the main file, not inside a class.register_activation_hook( MYPLUGIN_FILE, array( 'My_Plugin', 'activate' ) );
register_deactivation_hook( MYPLUGIN_FILE, array( 'My_Plugin', 'deactivate' ) );
// Kick off.My_Plugin::get_instance();
Asset loading rules
WordPress plugins must load all JavaScript and CSS through the enqueue API using external files. Printing <script> or <style> tags directly in PHP output is forbidden — it bypasses WordPress dependency management, breaks Content Security Policy headers, prevents caching and deduplication, and is flagged by the Plugin Review Team.
The only acceptable way to add small amounts of dynamic CSS or JS is through wp_add_inline_style() and wp_add_inline_script(), which attach the code to a properly enqueued handle.
Plugin header requirements for wordpress.org
Field
Required
Notes
Plugin Name
Yes
Unique, descriptive
Description
Yes
Max 150 characters recommended
Version
Yes
Semantic versioning (1.0.0)
Requires at least
Yes
Minimum WordPress version
Requires PHP
Yes
Minimum PHP version
Author
Yes
Your name or company
License
Yes
Must be GPL-2.0-or-later or compatible
Text Domain
Yes
Must match the plugin folder slug
Domain Path
Deprecated
Do no add this line
Requires Plugins
Optional (WP 6.5+)
Comma-separated plugin slugs that must be active before this plugin activates. WordPress prompts the user to install/activate them. Older WP versions ignore the header silently, so it is safe to declare even on plugins that target 6.0+.
WC requires at least
WooCommerce add-ons only
Minimum WooCommerce version. Recognised by WC itself, not by WordPress core.
WC tested up to
WooCommerce add-ons only
Latest WooCommerce version you tested against.
Plugin lifecycle
Activation hook
Runs when the plugin is activated. Use it to create database tables, set default options, and schedule cron events.
// CORRECT: Activation - set up what the plugin needs to runpublicstaticfunctionactivate() {
// Check capabilities - prevents direct URL activation exploitsif ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
// Create custom tablesself::create_tables();
// Set default options (only if they don't exist yet)if ( false === get_option( 'myplugin_settings' ) ) {
add_option( 'myplugin_settings', array(
'enabled' => true,
'limit' => 10,
), '', 'yes' ); // 'yes' = autoload
}
// Schedule cron eventsif ( ! wp_next_scheduled( 'myplugin_daily_task' ) ) {
wp_schedule_event( time(), 'daily', 'myplugin_daily_task' );
}
// Store plugin version for future upgrade checksupdate_option( 'myplugin_version', MYPLUGIN_VERSION );
// Flush rewrite rules if registering CPTsflush_rewrite_rules();
}
// WRONG: Never run heavy logic or queries during activation without guardspublicstaticfunctionactivate() {
$results = $wpdb->get_results( "SELECT * FROM {$wpdb->posts}" ); // Never!wp_remote_get( 'https://api.example.com/register' ); // Never!
}
Deactivation hook
Runs when the plugin is deactivated. Clean up temporary data and scheduled events. Do NOT delete user data here.
// CORRECT: Deactivation - stop scheduled tasks, clear transientspublicstaticfunctiondeactivate() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
// Remove scheduled cron eventswp_clear_scheduled_hook( 'myplugin_daily_task' );
// Clear transientsdelete_transient( 'myplugin_cache' );
// Flush rewrite rules (remove CPT slugs from .htaccess)flush_rewrite_rules();
// WRONG: Do NOT delete options or tables here - that is uninstall logic
}
Uninstall logic
Runs only when the user deletes the plugin. This is where you permanently remove all plugin data.
// OPTION A: uninstall.php in the plugin root (recommended for complex cleanup)<?php// Prevent direct accessif ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// Delete optionsdelete_option( 'myplugin_settings' );
delete_option( 'myplugin_version' );
// Delete user metadelete_metadata( 'user', 0, 'myplugin_preference', '', true );
// Drop custom tablesglobal$wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_data" );
// Delete all plugin transients$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '\_transient\_myplugin\_%'
OR option_name LIKE '\_transient\_timeout\_myplugin\_%'"
);
// OPTION B: register_uninstall_hook() in main file (for simple cleanup only)// register_uninstall_hook( MYPLUGIN_FILE, 'myplugin_uninstall' );// Note: uninstall.php takes precedence over register_uninstall_hook()
Path detection in uninstall.php
The Plugin Review Team flags any combination of WP_PLUGIN_DIR with a hardcoded slug literal as "hardcoded plugin folder path". It does not matter if the code is conceptually correct (e.g. detecting a legacy parallel install); the reviewer will reject it.
// REJECTED: even though it would work, the linter flags the slug literal.$canonical = trailingslashit( WP_PLUGIN_DIR ) . 'my-plugin';
if ( __DIR__ !== untrailingslashit( $canonical ) && is_dir( $canonical ) ) {
return;
}
// ACCEPTED: derive the path from __FILE__ instead.$plugin_dir = plugin_dir_path( __FILE__ ); // or use __DIR__
If you genuinely need a safeguard for legacy installation folders (e.g. users who downloaded a GitHub source ZIP before the canonical wordpress.org release existed), document the upgrade path in the FAQ instead and let the standard install/uninstall flow handle it.
Lifecycle comparison
Hook
When it runs
Use for
register_activation_hook
On activation click
Create tables, default options, schedule cron
register_deactivation_hook
On deactivation click
Clear cron, flush rewrites, delete transients
uninstall.php
On plugin deletion
Delete all options, tables, user meta
plugins_loaded
Every request, after plugins load
Initialize plugin classes
init
Every request
Register CPTs, taxonomies, shortcodes
Main plugin class
Use a singleton to avoid multiple instantiations and keep global state controlled.
// ACTION: do something at a point in execution (no return value needed)add_action( 'save_post', 'myplugin_on_save_post', 10, 2 );
functionmyplugin_on_save_post(int$post_id, WP_Post $post): void{
// Do something when a post is saved
}
// FILTER: modify a value and return it (always return the value!)add_filter( 'the_content', 'myplugin_filter_content', 10, 1 );
functionmyplugin_filter_content(string$content): string{
// Modify and always returnreturn$content . '<p>Added by plugin</p>';
}
// WRONG: Forgetting to return in a filter breaks the siteadd_filter( 'the_content', function( $content ) {
echo$content; // Never echo in a filter!// No return = null is returned, content disappears
} );
Hook priorities
// Default priority is 10. Lower = earlier, higher = later.add_action( 'init', 'myplugin_early_init', 5 ); // Runs before defaultadd_action( 'init', 'myplugin_normal_init' ); // Priority 10 (default)add_action( 'init', 'myplugin_late_init', 20 ); // Runs after default// Number of accepted arguments (4th parameter)add_action( 'save_post', 'myplugin_handler', 10, 3 ); // $post_id, $post, $update
Removing hooks
// To remove a hook added with a named functionremove_action( 'wp_head', 'wp_generator' );
// To remove a hook added with a class method - needs same instance$instance = My_Plugin::get_instance();
remove_action( 'init', array( $instance, 'some_method' ) );
// WRONG: This does not work for anonymous functions (no reference)$fn = function() { /* ... */ };
add_action( 'init', $fn );
remove_action( 'init', $fn ); // Works only if $fn is still in scope
Filters that depend on a specific call site
Some filters only fire in a specific code path. The most common pitfall: shortcode_atts_{tag} only fires when WordPress calls shortcode_atts() from inside do_shortcode(). If the same render function is invoked directly from another context (a custom WooCommerce endpoint, a Gutenberg block render callback, a REST response, an admin_post_* handler), the filter never runs.
// WRONG: The filter only applies when the form is rendered via [my_form] shortcode.// Calling my_render_form() from a WC endpoint silently skips the prefill.add_filter( 'shortcode_atts_my_form', 'my_prefill_from_query' );
functionmy_prefill_from_query($atts) {
if ( isset( $_GET['order_id'] ) ) {
$atts['order_id'] = sanitize_text_field( wp_unslash( $_GET['order_id'] ) );
}
return$atts;
}
// CORRECT: Extract the logic to a helper that both call sites invoke.functionmy_get_prefill_order_id() {
// ... nonce check + sanitize + return value ...return$order_id;
}
// Endpoint caller passes the value directly:functionmy_account_endpoint_content() {
my_render_form( array(
'order_id' => my_get_prefill_order_id(),
) );
}
// Shortcode caller goes through the filter, which uses the same helper:add_filter( 'shortcode_atts_my_form', function( $atts ) {
$order_id = my_get_prefill_order_id();
if ( '' !== $order_id ) {
$atts['order_id'] = $order_id;
}
return$atts;
} );
Same pattern applies to any filter named after a specific hook context: the_content filters do not run on raw post body access, the_title does not run on get_the_title() in some admin contexts, etc. When in doubt, extract the logic and call it from every entry point.
Settings API
The Settings API handles validation, storage, and security for plugin options. Never save options manually with $_POST.
Complete Settings API implementation
<?php// Prevent direct access.if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Handles plugin settings using the WordPress Settings API.
*/classMy_Plugin_Settings{
/** @var string Option name in wp_options */constOPTION_NAME = 'myplugin_settings';
/** @var string Settings page slug */constPAGE_SLUG = 'myplugin-settings';
/** @var string Settings group (must match register_setting) */constOPTION_GROUP = 'myplugin_options_group';
/**
* Register settings, sections, and fields.
* Hooked to admin_init.
*/publicfunctionregister(): void{
// Register the option with a sanitize callbackregister_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
array(
'sanitize_callback' => array( $this, 'sanitize_settings' ),
'default' => $this->get_defaults(),
)
);
// Add a sectionadd_settings_section(
'myplugin_general_section',
__( 'General Settings', 'my-plugin' ),
array( $this, 'render_general_section' ),
self::PAGE_SLUG
);
// Add fields to the sectionadd_settings_field(
'myplugin_field_enabled',
__( 'Enable feature', 'my-plugin' ),
array( $this, 'render_field_enabled' ),
self::PAGE_SLUG,
'myplugin_general_section'
);
add_settings_field(
'myplugin_field_limit',
__( 'Results limit', 'my-plugin' ),
array( $this, 'render_field_limit' ),
self::PAGE_SLUG,
'myplugin_general_section'
);
}
/**
* Sanitize all settings on save.
* This is the only place where $_POST data is processed.
*
* @param array $input Raw input from the form.
* @return array Sanitized settings.
*/publicfunctionsanitize_settings(array$input): array{
$sanitized = $this->get_defaults();
// Checkbox: present = true, absent = false$sanitized['enabled'] = isset( $input['enabled'] );
// Integer with range validationif ( isset( $input['limit'] ) ) {
$limit = absint( $input['limit'] );
$sanitized['limit'] = ( $limit >= 1 && $limit <= 100 ) ? $limit : 10;
}
// Text fieldif ( isset( $input['api_key'] ) ) {
$sanitized['api_key'] = sanitize_text_field( $input['api_key'] );
}
// Select with safelist validation$allowed_modes = array( 'simple', 'advanced' );
if ( isset( $input['mode'] ) && in_array( $input['mode'], $allowed_modes, true ) ) {
$sanitized['mode'] = $input['mode'];
}
return$sanitized;
}
/**
* Get default settings values.
*/publicfunctionget_defaults(): array{
returnarray(
'enabled' => true,
'limit' => 10,
'api_key' => '',
'mode' => 'simple',
);
}
/**
* Get a single setting value with fallback to default.
*
* @param string $key Setting key.
* @return mixed Setting value.
*/publicfunctionget(string$key) {
$settings = get_option( self::OPTION_NAME, $this->get_defaults() );
$defaults = $this->get_defaults();
return$settings[ $key ] ?? $defaults[ $key ] ?? null;
}
/**
* Render the settings section description.
*/publicfunctionrender_general_section(): void{
echo'<p>' . esc_html__( 'Configure the general plugin behavior.', 'my-plugin' ) . '</p>';
}
/**
* Render the "enabled" checkbox field.
*/publicfunctionrender_field_enabled(): void{
$value = $this->get( 'enabled' );
printf(
'<input type="checkbox" id="myplugin_field_enabled" name="%s[enabled]" value="1" %s>',
esc_attr( self::OPTION_NAME ),
checked( $value, true, false )
);
echo'<label for="myplugin_field_enabled">' .
esc_html__( 'Enable the main feature', 'my-plugin' ) .
'</label>';
}
/**
* Render the "limit" number field.
*/publicfunctionrender_field_limit(): void{
$value = $this->get( 'limit' );
printf(
'<input type="number" id="myplugin_field_limit" name="%s[limit]" value="%d" min="1" max="100" class="small-text">',
esc_attr( self::OPTION_NAME ),
absint( $value )
);
echo'<p class="description">' .
esc_html__( 'Number of results to show (1-100).', 'my-plugin' ) .
'</p>';
}
}
Admin menu and settings page
/**
* Registers admin menu pages.
* Hooked to admin_menu.
*/publicfunctionadd_admin_menu(): void{
// Top-level menu pageadd_menu_page(
__( 'My Plugin', 'my-plugin' ), // Page title
__( 'My Plugin', 'my-plugin' ), // Menu title
'manage_options', // Capability required
'myplugin-settings', // Menu slug
array( $this, 'render_settings_page' ), // Callback'dashicons-admin-generic', // Icon80// Position
);
// Submenu page (can also add submenus under existing menus)add_submenu_page(
'myplugin-settings', // Parent slug
__( 'My Plugin Settings', 'my-plugin' ), // Page title
__( 'Settings', 'my-plugin' ), // Menu title
'manage_options',
'myplugin-settings',
array( $this, 'render_settings_page' )
);
}
/**
* Render the settings page.
* settings_fields() and do_settings_sections() do all the heavy lifting.
*/publicfunctionrender_settings_page(): void{
// Always check capabilities again before renderingif ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to access this page.', 'my-plugin' ) );
}
?>
<div class="wrap">
<h1><?phpechoesc_html( get_admin_page_title() ); ?></h1>
<?phpsettings_errors( 'myplugin_messages' ); ?>
<formmethod="post" action="options.php">
<?php
// Outputnonce, action, andoption_pagefieldssettings_fields( My_Plugin_Settings::OPTION_GROUP );
// Outputtheregisteredsectionsandfieldsdo_settings_sections( My_Plugin_Settings::PAGE_SLUG );
submit_button( __( 'Savesettings', 'my-plugin' ) );
?>
</form>
</div>
<?php
}
Only create custom tables when WordPress's existing data structures (posts, meta, options) genuinely cannot serve the use case.
Creating tables with dbDelta
/**
* Creates or updates the custom database table.
* Uses dbDelta() which handles both CREATE and ALTER safely.
*/publicstaticfunctioncreate_tables(): void{
global$wpdb;
$table_name = $wpdb->prefix . 'myplugin_data';
$charset_collate = $wpdb->get_charset_collate();
// dbDelta requires specific formatting:// - Two spaces before field definitions// - PRIMARY KEY must be uppercase// - Each line ends with a comma (except the last field before the closing paren)$sql = "CREATE TABLE {$table_name} (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
post_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
data longtext NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_id (user_id),
KEY post_id (post_id)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
// Store the table version for future upgradesupdate_option( 'myplugin_db_version', '1.0' );
}
/**
* Run table upgrades when plugin version changes.
* Hook to plugins_loaded.
*/publicfunctionmaybe_upgrade(): void{
$installed = get_option( 'myplugin_db_version', '0' );
if ( version_compare( $installed, '1.1', '<' ) ) {
global$wpdb;
$table = $wpdb->prefix . 'myplugin_data';
// dbDelta handles adding new columns safely$sql = "CREATE TABLE {$table} (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
user_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
post_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
data longtext NOT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
priority tinyint(3) UNSIGNED NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (id),
KEY user_id (user_id)
) {$wpdb->get_charset_collate()};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
update_option( 'myplugin_db_version', '1.1' );
}
}
dbDelta formatting rules
Rule
Correct
Wrong
Field indentation
Two spaces
One space or tab
PRIMARY KEY spacing
PRIMARY KEY (id)
PRIMARY KEY (id)
Index naming
KEY user_id (user_id)
INDEX user_id (user_id)
No trailing comma
Last field has no comma
Trailing comma on last field
Always use $wpdb->prefix
{$wpdb->prefix}table
Hardcoded wp_table
Internationalization
Every user-facing string must be wrapped in a localization function. This is mandatory for wordpress.org.
// CORRECT: All user-facing strings wrapped and escapedecho'<h2>' . esc_html__( 'Plugin Settings', 'my-plugin' ) . '</h2>';
// CORRECT: Singular/pluralprintf(
/* translators: %d: number of items */esc_html( _n( '%d item found.', '%d items found.', $count, 'my-plugin' ) ),
absint( $count )
);
// CORRECT: Context for disambiguation (same word, different meaning)$label = _x( 'Draft', 'post status', 'my-plugin' );
$label = _x( 'Draft', 'button label', 'my-plugin' );
// CORRECT: Variable in translated string - use printf/sprintf, not concatenationprintf(
/* translators: %s: user display name */esc_html__( 'Hello, %s!', 'my-plugin' ),
esc_html( $user->display_name )
);
// WRONG: Concatenating strings breaks translationechoesc_html__( 'Hello, ', 'my-plugin' ) . esc_html( $name ) . '!';
// WRONG: Translating variable content$status = 'published';
echoesc_html__( $status, 'my-plugin' ); // Translators can't see this!
Text domain rules for wordpress.org
// CORRECT: Text domain is a string literal, matches plugin folder slug__( 'text', 'my-plugin' );
// WRONG: Variable text domain - prevents string extraction$domain = 'my-plugin';
__( 'text', $domain );
// The text domain in function calls MUST match the Text Domain header in the plugin file// and the plugin folder name on wordpress.org
Translation template generation
There is no need to generate a .pot file because de use of Domain Path is deprecated
load_plugin_textdomain() is not needed since WordPress 4.6.
Plugin dependencies
Checking for required plugins
// CORRECT: Check on plugins_loaded (all plugins are loaded)add_action( 'plugins_loaded', 'myplugin_check_dependencies' );
functionmyplugin_check_dependencies(): void{
// Check if WooCommerce is activeif ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', 'myplugin_woo_missing_notice' );
// Optionally deactivate selfdeactivate_plugins( plugin_basename( MYPLUGIN_FILE ) );
return;
}
// Check minimum WooCommerce versionif ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.0', '<' ) ) {
add_action( 'admin_notices', 'myplugin_woo_version_notice' );
return;
}
// All good - initialize the pluginMy_Plugin::get_instance();
}
functionmyplugin_woo_missing_notice(): void{
echo'<div class="notice notice-error"><p>' .
sprintf(
/* translators: %s: plugin name */esc_html__( 'My Plugin requires %s to be installed and active.', 'my-plugin' ),
'<strong>WooCommerce</strong>'
) .
'</p></div>';
}
Apply the correct esc_* function at every output point
Missing nonce verification
Add check_admin_referer() or wp_verify_nonce() to all form handlers
Using $_POST directly
Always sanitize with the appropriate sanitize_* function
Calling external URLs on every load
Cache responses with transients; move requests to cron
Hardcoded database prefix (wp_)
Always use $wpdb->prefix
eval() usage
Never use eval() — rejected automatically
Non-GPL bundled code
All included libraries must be GPL-compatible
Missing ABSPATH check
Add to every PHP file except the main plugin file
error_reporting() calls
Remove entirely; never ship debug code
Overwriting WordPress globals
Never modify $wp_query, $wpdb, etc. globally
extract() usage
Forbidden — creates unpredictable variable scope
Generic function/class names
Prefix everything with a unique identifier
Short or generic prefix (under 4 characters)
Use a unique prefix of at least 4 characters for all functions, classes, constants, hooks, and handles
Inconsistent prefix across identifiers
Keep one dominant prefix for the whole plugin; never mix two unrelated 4-character prefixes
Inline
Use wp_enqueue_script() / wp_enqueue_style() with external files; use wp_add_inline_script() / wp_add_inline_style() only for small dynamic values
phpcs:ignore on security sniffs
Refactor the code so the sniffer is satisfied without suppression (see wp-plugin-security skill)
echo helper() where helper returns HTML
Wrap in wp_kses_post() / wp_kses() or refactor the helper to echo directly
Hardcoded slug literal under WP_PLUGIN_DIR
Use plugin_dir_path( __FILE__ ) / __DIR__
Admin-wide promotional notices
See "Promotional UI inside the admin" below
Promotional UI inside the admin (Guideline 11)
The Plugin Review Team explicitly forbids "hijacking the admin dashboard" with promotional content. A cross-promo box (linking to your other plugins or paid services) is acceptable if and only if all of these hold:
Rendered exclusively from the plugin's own settings screen callback. Never hooked into admin_notices globally.
No "dismissed forever" cookies or user-meta flags whose only purpose is to re-show the nag.
No HTTP requests to an external service to decide which item to show. Use a static catalog inside the plugin.
No Dashboard widgets, no top-bar promos, no pop-ups, no toolbar items.
// CORRECT: instantiation lives inside the settings page callback.functionayudawp_settings_page_html() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die();
}
?>
<div class="wrap">
<h1><?phpechoesc_html( get_admin_page_title() ); ?></h1>
<formmethod="post" action="options.php">
<?phpsettings_fields( 'ayudawp_options_group' ); ?>
<?phpdo_settings_sections( 'ayudawp-settings' ); ?>
<?phpsubmit_button(); ?>
</form>
<?php
// Promoboxonlyappearshere, neveronotheradminscreens.
if ( class_exists( 'Ayudawp_Promo_Banner' ) ) {
( newAyudawp_Promo_Banner( 'my-plugin' ) )->render();
}
?>
</div>
<?php
}
// WRONG: a global admin_notices hook shows the promo on every admin screen.add_action( 'admin_notices', 'ayudawp_show_promo' );
The other three legitimate uses of admin_notices are: the one-shot welcome notice after activation (gated by a short-lived transient), feedback after a bulk action (gated by a $_GET flag that comes from your own redirect with a nonce), and validation errors after a save (gated by a user-scoped transient). All three appear only once and only when there is a real event to communicate.
readme.txt structure
=== Plugin Name ===
Contributors: yourusername, secondcontributor
Tags: tag1, tag2, tag3, tag4, tag5
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Short description under 150 characters. No markup.
== Description ==
Full description of the plugin. Supports Markdown.
== Installation ==
1. Upload the plugin folder to `/wp-content/plugins/`.
2. Activate the plugin through the 'Plugins' menu in WordPress.
3. Go to Settings > My Plugin to configure.
== Frequently Asked Questions ==
= How do I configure the plugin? =
Go to Settings > My Plugin.
== Screenshots ==
1. Screenshot description (matches screenshot-1.png in /assets/).
== Changelog ==
= 1.0.0 =
* Initial release.
== Upgrade Notice ==
= 1.0.0 =
Initial release.
readme.txt rules for wordpress.org
Maximum 5 tags and no duplicates (a Tags: line with woocommerce, eu, woocommerce, … counts the duplicate and fails the check)
All tags in English (the directory is international; localized tags get little traffic and crowd the slot count)
Short description: 150 characters maximum, no HTML
Upgrade notice: under 300 characters
No Network header (means network-only activation, which is rarely correct)
Tested up to must reflect the latest WordPress version you have tested
Stable tag must match the actual tag in the SVN repository, and the Version: in the main plugin file header, and the version constant defined inside the plugin — all three must agree on every release
Changelog must be present and maintained — keep only the latest major and its minor releases in readme.txt; move older entries to a separate changelog.txt if you want to keep the full history available
"Upgrade Notice" should contain only the latest version's notice; replace it on every release (not accumulate)
No donation links unless approved by the Plugin Review Team
Avoid release-process noise in the changelog: lines like "Internal: WPCS pass", "Dev tooling: composer.json added", "Refactored functions-admin.php into smaller files for maintenance, no behavioural change" are not user-facing and belong in changelog.txt or a git tag message, not in the public readme
Assets for the wordpress.org plugin page
Place these in the /assets/ folder in the SVN root (not inside the plugin folder):
File
Size
Format
banner-772x250.png or .jpg
772×250px
Plugin page banner
banner-1544x500.png or .jpg
1544×500px
High-DPI banner
icon-128x128.png
128×128px
Plugin icon
icon-256x256.png
256×256px
High-DPI icon
screenshot-1.png
Any
Must match screenshots in readme
Screenshots live in /assets/ at the SVN root, not inside the plugin folder. Many authors keep a working folder of screenshots inside their development repo (with descriptive filenames in their own language) and only push the final screenshot-1.png … screenshot-N.png to SVN /assets/. Do not ship either folder inside the ZIP that goes to /trunk/.
Files and folders to keep OUT of the ZIP
The plugin ZIP uploaded to wp.org (and committed to /trunk/ in SVN) should contain only what the plugin needs at runtime. Use a .distignore file (recognised by 10up's GitHub Action and similar tooling) or build the ZIP manually with explicit excludes.
Typical exclusion list:
# Development metadata
.git
.gitignore
.gitattributes
.github
.editorconfig
.distignore
node_modules
package.json
package-lock.json
composer.json
composer.lock
phpcs.xml
phpcs.xml.dist
phpunit.xml
phpunit.xml.dist
tests
# Author-private documentation (the GitHub readme, internal notes)
README.md
CHANGELOG.md
RELEASING.md
CLAUDE.md
docs
# Source assets for the SVN /assets/ folder
capturas
screenshots
# Translations (managed via translate.wordpress.org / GlotPress)
languages
# macOS noise
.DS_Store
languages/ is excluded when you delegate translations to GlotPress (the normal case for plugins published on wp.org). If you ship the .pot and an initial .mo inside the plugin, keep the folder.
README.md is the GitHub-flavoured readme; wp.org reads readme.txt. Shipping both confuses users who clone from GitHub vs. install from wp.org.
Debugging
Debug constants
// In wp-config.php for development (never ship with these enabled)define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true ); // Writes to /wp-content/debug.logdefine( 'WP_DEBUG_DISPLAY', false ); // Never display errors on screen in productiondefine( 'SAVEQUERIES', true ); // Logs all DB queries (expensive - dev only)define( 'SCRIPT_DEBUG', true ); // Loads unminified JS/CSS