| name | wp-plugin-development |
| description | 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.1"} |
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.
Plugin file structure
A well-organized plugin is easier to review, maintain, and extend.
Recommended structure
my-plugin/
├── my-plugin.php # Main plugin file (bootstrap only)
├── readme.txt # wordpress.org readme (required)
├── uninstall.php # Uninstall logic (alternative to hook)
├── assets/
│ ├── css/
│ │ ├── admin.css
│ │ └── public.css
│ ├── js/
│ │ ├── admin.js
│ │ └── public.js
│ └── images/
├── includes/
│ ├── class-my-plugin.php # Main plugin class
│ ├── class-my-plugin-admin.php # Admin-specific functionality
│ ├── class-my-plugin-public.php # Public-facing functionality
│ ├── class-my-plugin-cpt.php # Custom post types / taxonomies
│ ├── class-my-plugin-db.php # Custom database tables
│ └── class-my-plugin-settings.php # Settings API implementation
Main plugin file
The main file is a bootstrap: it defines constants, checks requirements, and loads the rest.
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
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__ ) );
function myplugin_meets_requirements() {
if ( version_compare( PHP_VERSION, '7.4', '<' ) ) {
return false;
}
if ( version_compare( get_bloginfo( 'version' ), , ) ) {
;
}
;
}
( ! () ) {
( , );
;
}
{
.
( , ) .
;
}
MYPLUGIN_DIR . ;
( MYPLUGIN_FILE, ( , ) );
( MYPLUGIN_FILE, ( , ) );
::();
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.
add_action( 'wp_head', 'myplugin_bad_inline_script' );
function myplugin_bad_inline_script() {
echo '<script>var config = { api: "https://example.com" };</script>';
}
add_action( 'wp_head', 'myplugin_bad_inline_style' );
function myplugin_bad_inline_style() {
echo '<style>.my-widget { color: red; }</style>';
}
wp_enqueue_script(
'myplugin-frontend',
MYPLUGIN_URL . 'assets/js/frontend.js',
array(),
MYPLUGIN_VERSION,
true
);
wp_localize_script( 'myplugin-frontend', 'mypluginConfig', array(
'api' => 'https://example.com',
) );
wp_enqueue_style(
'myplugin-frontend',
MYPLUGIN_URL . 'assets/css/frontend.css',
array(),
MYPLUGIN_VERSION
);
$custom_color = sanitize_hex_color( get_option( 'myplugin_color', '#333' ) );
( , );
( , , );
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 |
Plugin lifecycle
Activation hook
Runs when the plugin is activated. Use it to create database tables, set default options, and schedule cron events.
public static function activate() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
self::create_tables();
if ( false === get_option( 'myplugin_settings' ) ) {
add_option( 'myplugin_settings', array(
'enabled' => true,
'limit' => 10,
), '', 'yes' );
}
if ( ! wp_next_scheduled( 'myplugin_daily_task' ) ) {
wp_schedule_event( time(), 'daily', 'myplugin_daily_task' );
}
update_option( 'myplugin_version', MYPLUGIN_VERSION );
flush_rewrite_rules();
}
{
= ->( );
( );
}
Deactivation hook
Runs when the plugin is deactivated. Clean up temporary data and scheduled events. Do NOT delete user data here.
public static function deactivate() {
if ( ! current_user_can( 'activate_plugins' ) ) {
return;
}
wp_clear_scheduled_hook( 'myplugin_daily_task' );
delete_transient( 'myplugin_cache' );
flush_rewrite_rules();
}
Uninstall logic
Runs only when the user deletes the plugin. This is where you permanently remove all plugin data.
<?php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'myplugin_settings' );
delete_option( 'myplugin_version' );
delete_metadata( 'user', 0, 'myplugin_preference', '', true );
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}myplugin_data" );
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '\_transient\_myplugin\_%'
OR option_name LIKE '\_transient\_timeout\_myplugin\_%'"
);
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.
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class My_Plugin {
private static $instance = null;
public static function get_instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->load_dependencies();
$this->define_hooks();
}
private function load_dependencies(): {
MYPLUGIN_DIR . ;
MYPLUGIN_DIR . ;
MYPLUGIN_DIR . ;
}
{
= ();
= ();
= _CPT();
( , ( , ) );
( , ( , ) );
( , ( , ) );
( , ( , ) );
( , ( , ) );
( , ( , ) );
( , ( , ) );
}
{
();
}
{
( );
();
}
}
Hooks system
Actions vs filters
add_action( 'save_post', 'myplugin_on_save_post', 10, 2 );
function myplugin_on_save_post( int $post_id, WP_Post $post ): void {
}
add_filter( 'the_content', 'myplugin_filter_content', 10, 1 );
function myplugin_filter_content( string $content ): string {
return $content . '<p>Added by plugin</p>';
}
add_filter( 'the_content', function( $content ) {
echo $content;
} );
Hook priorities
add_action( 'init', 'myplugin_early_init', 5 );
add_action( 'init', 'myplugin_normal_init' );
add_action( 'init', 'myplugin_late_init', 20 );
add_action( 'save_post', 'myplugin_handler', 10, 3 );
Removing hooks
remove_action( 'wp_head', 'wp_generator' );
$instance = My_Plugin::get_instance();
remove_action( 'init', array( $instance, 'some_method' ) );
$fn = function() { };
add_action( 'init', $fn );
remove_action( 'init', $fn );
Settings API
The Settings API handles validation, storage, and security for plugin options. Never save options manually with $_POST.
Complete Settings API implementation
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class My_Plugin_Settings {
const OPTION_NAME = 'myplugin_settings';
const PAGE_SLUG = 'myplugin-settings';
const OPTION_GROUP = 'myplugin_options_group';
public function register(): void {
register_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
array(
'sanitize_callback' => array( $this, 'sanitize_settings' ),
'default' => $this->get_defaults(),
)
);
(
,
( , ),
( , ),
::
);
(
,
( , ),
( , ),
::,
);
(
,
( , ),
( , ),
::,
);
}
{
= ->();
[] = ( [] );
( ( [] ) ) {
= ( [] );
[] = ( >= && <= ) ? : ;
}
( ( [] ) ) {
[] = ( [] );
}
= ( , );
( ( [] ) && ( [], , ) ) {
[] = [];
}
;
}
{
(
=> ,
=> ,
=> ,
=> ,
);
}
{
= ( ::, ->() );
= ->();
[ ] ?? [ ] ?? ;
}
{
. ( , ) . ;
}
{
= ->( );
(
,
( :: ),
( , , )
);
.
( , ) .
;
}
{
= ->( );
(
,
( :: ),
( )
);
.
( , ) .
;
}
}
Admin menu and settings page
public function add_admin_menu(): void {
add_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' ),
'dashicons-admin-generic',
80
);
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' )
);
}
public (): {
( ! ( ) ) {
( ( , ) );
}
<div
Custom post types and taxonomies
Registering a custom post type
public function register_post_types(): void {
$labels = array(
'name' => _x( 'Events', 'post type general name', 'my-plugin' ),
'singular_name' => _x( 'Event', 'post type singular name', 'my-plugin' ),
'menu_name' => _x( 'Events', 'admin menu', 'my-plugin' ),
'add_new' => __( 'Add new', 'my-plugin' ),
'add_new_item' => __( 'Add new event', 'my-plugin' ),
'edit_item' => __( 'Edit event', 'my-plugin' ),
'not_found' => __( 'No events found.', 'my-plugin' ),
'not_found_in_trash' => __( 'No events found in trash.', 'my-plugin' ),
);
$args = array(
'labels' => $labels,
=> ,
=> ,
=> ,
=> ,
=> ,
=> ,
=> ( , , , ),
=> ,
=> ( => ),
=> ,
);
( , );
}
Registering a custom taxonomy
public function register_taxonomies(): void {
$labels = array(
'name' => _x( 'Event Categories', 'taxonomy general name', 'my-plugin' ),
'singular_name' => _x( 'Event Category', 'taxonomy singular name', 'my-plugin' ),
'search_items' => __( 'Search event categories', 'my-plugin' ),
'all_items' => __( 'All event categories', 'my-plugin' ),
'edit_item' => __( 'Edit event category', 'my-plugin' ),
'update_item' => __( 'Update event category', 'my-plugin' ),
'add_new_item' => __( 'Add new event category', 'my-plugin' ),
'not_found' => __( 'No event categories found.', 'my-plugin' ),
);
register_taxonomy(
'myplugin_event_cat', // Taxonomy slug
array( 'myplugin_event' ),
(
=> ,
=> ,
=> ,
=> ,
=> ,
=> ( => ),
)
);
}
Custom database tables
Only create custom tables when WordPress's existing data structures (posts, meta, options) genuinely cannot serve the use case.
Creating tables with dbDelta
public static function create_tables(): void {
global $wpdb;
$table_name = $wpdb->prefix . 'myplugin_data';
$charset_collate = $wpdb->get_charset_collate();
$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 );
update_option( 'myplugin_db_version', '1.0' );
}
public function maybe_upgrade(): void {
= ( , );
( ( , , ) ) {
;
= ->prefix . ;
= ;
ABSPATH . ;
( );
( , );
}
}
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.
Localization functions
| Function | Use case |
|---|
__( 'text', 'domain' ) | Return translated string |
_e( 'text', 'domain' ) | Echo translated string |
_x( 'text', 'context', 'domain' ) | With disambiguation context |
_n( 'singular', 'plural', $count, 'domain' ) | Singular/plural |
_nx( 'sing', 'plur', $count, 'context', 'domain' ) | Plural with context |
esc_html__( 'text', 'domain' ) | Return translated + escaped |
esc_html_e( 'text', 'domain' ) | Echo translated + escaped |
esc_attr__( 'text', 'domain' ) | Return for attribute context |
i18n examples
echo '<h2>' . esc_html__( 'Plugin Settings', 'my-plugin' ) . '</h2>';
printf(
esc_html( _n( '%d item found.', '%d items found.', $count, 'my-plugin' ) ),
absint( $count )
);
$label = _x( 'Draft', 'post status', 'my-plugin' );
$label = _x( 'Draft', 'button label', 'my-plugin' );
printf(
esc_html__( 'Hello, %s!', 'my-plugin' ),
esc_html( $user->display_name )
);
echo esc_html__( 'Hello, ', 'my-plugin' ) . esc_html( $name ) . '!';
= ;
( , );
Text domain rules for wordpress.org
__( 'text', 'my-plugin' );
$domain = 'my-plugin';
__( 'text', $domain );
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
add_action( 'plugins_loaded', 'myplugin_check_dependencies' );
function myplugin_check_dependencies(): void {
if ( ! class_exists( 'WooCommerce' ) ) {
add_action( 'admin_notices', 'myplugin_woo_missing_notice' );
deactivate_plugins( plugin_basename( MYPLUGIN_FILE ) );
return;
}
if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '7.0', '<' ) ) {
add_action( 'admin_notices', 'myplugin_woo_version_notice' );
return;
}
My_Plugin::get_instance();
}
function myplugin_woo_missing_notice(): void {
echo '<div class="notice notice-error"><p>' .
sprintf(
esc_html__( , ),
) .
;
}
Checking for PHP extensions
$missing_extensions = array();
if ( ! extension_loaded( 'curl' ) ) {
$missing_extensions[] = 'cURL';
}
if ( ! extension_loaded( 'mbstring' ) ) {
$missing_extensions[] = 'mbstring';
}
if ( ! empty( $missing_extensions ) ) {
add_action( 'admin_notices', function() use ( $missing_extensions ) {
echo '<div class="notice notice-error"><p>' .
sprintf(
/* translators: %s: comma-separated list of PHP extensions */
esc_html__( 'My Plugin requires the following PHP extensions: %s', 'my-plugin' ),
'<strong>' . esc_html( implode( ', ', $missing_extensions ) ) . '</strong>'
) .
'</></>';
} );
;
}
wordpress.org submission requirements
Common rejection reasons
| Issue | Fix |
|---|
| Unescaped output | 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 |
| 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 |
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
- 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
- Changelog must be present and maintained
- No donation links unless approved by the Plugin Review Team
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 |
Debugging
Debug constants
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'SAVEQUERIES', true );
define( 'SCRIPT_DEBUG', true );
Logging in plugin code
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[My Plugin] Unexpected value: ' . print_r( $value, true ) );
}
function myplugin_log( string $message, $data = null ): void {
if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
return;
}
$entry = '[My Plugin] ' . $message;
if ( null !== $data ) {
$entry .= ' | ' . print_r( $data, true );
}
error_log( $entry );
}
var_dump( $variable );
print_r( $variable );
echo '<pre>' . $output . '</pre>';
Testing with WP_CLI
wp eval 'var_dump( get_option( "myplugin_settings" ) );'
wp cron event list
wp cron event run myplugin_daily_task
wp plugin verify-checksums my-plugin
wp i18n make-pot . languages/my-plugin.pot
Code review checklist
File structure and header
Lifecycle
Settings API
Custom post types and taxonomies
Custom database tables
Internationalization
Hooks and architecture
wordpress.org compliance
References