WordPress Plugin Development: The Complete Guide (2026)
Table of Contents
WordPress powers around 42% of the web, and the plugin ecosystem is the engine behind most of that flexibility. Whether you are building a simple utility or a complex SaaS add-on, understanding WordPress plugin development from the ground up is what separates a competent WordPress developer from everyone else. This guide covers everything that matters in 2026: plugin structure, the hooks system, security best practices, the REST API, the new Abilities API introduced in WordPress 6.9, and how AI coding assistants like Claude Code and Cursor β paired with an MCP-connected WordPress site β are changing the development workflow.
This is not a quick-start tutorial that stops at βHello World.β It is a practitionerβs reference. By the end you will know how to build a production-grade plugin, submit it to WordPress.org, and wire it up so that AI agents can discover and execute its functionality.
Plugin Structure
A WordPress plugin is a PHP file (or a directory of PHP files) placed inside wp-content/plugins/. WordPress reads the file header of the main plugin file to identify the plugin. At minimum, a valid plugin needs one file with this header:
<?php
/**
* Plugin Name: My Plugin
* Plugin URI: https://example.com/my-plugin
* Description: A short description of the plugin.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.1
* 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
* Domain Path: /languages
*/
For anything beyond a trivial snippet, use a directory structure. The WordPress Plugin Boilerplate (available on GitHub) is a widely-referenced starting point, though it has seen no active maintenance since around 2019 β treat it as a structural example to adapt rather than a maintained dependency:
my-plugin/
βββ my-plugin.php # Main file with plugin header
βββ uninstall.php # Runs on plugin deletion (use delete_option(), etc.)
βββ includes/
β βββ class-my-plugin.php # Core plugin class
β βββ class-my-plugin-loader.php # Hook registration
β βββ class-my-plugin-activator.php
βββ admin/
β βββ class-my-plugin-admin.php
β βββ js/ css/ partials/
βββ public/
β βββ class-my-plugin-public.php
β βββ js/ css/
βββ languages/
βββ my-plugin.pot
Keep activation and deactivation logic in dedicated classes. Use register_activation_hook() and register_deactivation_hook() β never run schema changes or option writes at the top level of your plugin file.
Hooks: Actions and Filters
The WordPress Plugin API is built on two types of hooks. Understanding the difference is non-negotiable.
Actions let you execute code at a specific point in WordPressβs execution. You register a callback with add_action() and WordPress calls it when do_action() fires that hook.
Filters let you modify data before WordPress uses it. You register a callback with add_filter(), receive the data as the first argument, modify it, and return the result. Filters must return a value.
// Action: run code when WordPress initializes
add_action( 'init', 'my_plugin_init' );
function my_plugin_init() {
// register post types, taxonomies, etc.
}
// Filter: modify the post title
add_filter( 'the_title', 'my_plugin_custom_title', 10, 2 );
function my_plugin_custom_title( string $title, int $post_id ): string {
if ( get_post_meta( $post_id, '_featured', true ) ) {
return 'β
' . $title;
}
return $title;
}
The third argument to add_action() and add_filter() is priority (default 10; lower runs earlier). The fourth is the number of arguments your callback accepts. Use named functions or static class methods rather than anonymous closures when you need to be able to call remove_action() or remove_filter() later.
Key hooks every plugin developer uses frequently:
| Hook | Type | When it fires |
|---|---|---|
plugins_loaded | Action | After all active plugins are loaded |
init | Action | After WordPress is set up, before headers sent |
admin_menu | Action | Registers admin menu pages |
admin_enqueue_scripts | Action | Enqueue assets in the admin |
wp_enqueue_scripts | Action | Enqueue assets on the front end |
save_post | Action | After a post is saved |
the_content | Filter | Filters post content before display |
wp_nav_menu_items | Filter | Modifies navigation menu HTML |
A Minimal Example Plugin
Here is a complete, functional plugin that adds a [recent_posts] shortcode to display the five most recent posts:
<?php
/**
* Plugin Name: Recent Posts Shortcode
* Description: Adds a [recent_posts] shortcode.
* Version: 1.0.0
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: recent-posts-shortcode
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Prevent direct file access
}
add_action( 'init', 'rps_register_shortcode' );
function rps_register_shortcode(): void {
add_shortcode( 'recent_posts', 'rps_render_recent_posts' );
}
function rps_render_recent_posts( array $atts ): string {
$atts = shortcode_atts(
array( 'count' => 5 ),
$atts,
'recent_posts'
);
$count = absint( $atts['count'] );
$posts = get_posts( array(
'numberposts' => $count,
'post_status' => 'publish',
) );
if ( empty( $posts ) ) {
return '<p>' . esc_html__( 'No posts found.', 'recent-posts-shortcode' ) . '</p>';
}
$output = '<ul class="rps-recent-posts">';
foreach ( $posts as $post ) {
$output .= sprintf(
'<li><a href="%s">%s</a></li>',
esc_url( get_permalink( $post->ID ) ),
esc_html( get_the_title( $post->ID ) )
);
}
$output .= '</ul>';
return $output;
}
Notice the patterns already present: ABSPATH check, absint() for integer sanitization, esc_url() and esc_html() for output escaping, and __() for translation. These are not optional polish β they are baseline requirements.
Enqueueing Scripts and Styles
Never use bare <script> or <link> tags. WordPressβs asset queue handles dependency management, deduplication, and version cache-busting.
add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_assets' );
function my_plugin_enqueue_assets(): void {
wp_enqueue_style(
'my-plugin-style',
plugin_dir_url( __FILE__ ) . 'public/css/my-plugin.css',
array(), // dependencies
MY_PLUGIN_VERSION // version string for cache busting
);
wp_enqueue_script(
'my-plugin-script',
plugin_dir_url( __FILE__ ) . 'public/js/my-plugin.js',
array( 'jquery' ), // dependencies
MY_PLUGIN_VERSION,
true // load in footer
);
// Pass PHP data to JavaScript
wp_localize_script( 'my-plugin-script', 'myPluginData', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_plugin_action' ),
) );
}
Use admin_enqueue_scripts for admin-only assets and check get_current_screen() to load assets only on the pages that need them β loading assets globally is a common performance mistake.
Settings and the Options API
WordPressβs Options API stores plugin settings as key-value pairs in the wp_options table. For simple plugins, this is usually all you need.
// Register settings using the Settings API
add_action( 'admin_init', 'my_plugin_register_settings' );
function my_plugin_register_settings(): void {
register_setting(
'my_plugin_options_group', // option group
'my_plugin_options', // option name
array(
'sanitize_callback' => 'my_plugin_sanitize_options',
'default' => array( 'api_key' => '', 'enabled' => false ),
)
);
}
function my_plugin_sanitize_options( array $input ): array {
return array(
'api_key' => sanitize_text_field( $input['api_key'] ?? '' ),
'enabled' => (bool) ( $input['enabled'] ?? false ),
);
}
// Retrieve options
$options = get_option( 'my_plugin_options', array() );
$api_key = $options['api_key'] ?? '';
For complex or high-frequency data, consider Custom Post Types, custom tables (registered via dbDelta() on activation), or transients (set_transient() / get_transient()) for cached data.
Security Essentials
Security is not a feature you bolt on at the end. These four practices are mandatory.
1. Sanitize Input
Every value coming from the user or an external source must be sanitized before it touches your database or business logic. Use the appropriate function for the data type:
sanitize_text_field()β plain textsanitize_email()β email addressesabsint()β positive integerswp_kses_post()β HTML allowed in postssanitize_key()β lowercase alphanumeric keys
2. Escape Output
Every dynamic value written to HTML must be escaped at the point of output. Use:
esc_html()β plain text in HTMLesc_attr()β HTML attribute valuesesc_url()β URLs inhrefandsrcesc_js()β inline JavaScript stringswp_json_encode()β JSON output
3. Nonces
Nonces (number used once) protect form submissions and AJAX requests from cross-site request forgery (CSRF).
// In your form
wp_nonce_field( 'my_plugin_save_meta', 'my_plugin_nonce' );
// In your save handler
if ( ! isset( $_POST['my_plugin_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['my_plugin_nonce'] ) ), 'my_plugin_save_meta' ) ) {
wp_die( 'Security check failed.' );
}
4. Capability Checks
Always verify the current user has permission to perform an action before executing it:
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'my-plugin' ) );
}
Use the most specific capability that makes sense β edit_posts, publish_posts, manage_options β rather than checking is_admin(), which only tells you whether you are in the admin panel, not what the user can do.
REST API Endpoints
The WordPress REST API (introduced in core in 4.7) is now the standard way to expose plugin data to JavaScript applications, mobile apps, and external services.
add_action( 'rest_api_init', 'my_plugin_register_routes' );
function my_plugin_register_routes(): void {
register_rest_route(
'my-plugin/v1', // namespace
'/items/(?P<id>\d+)', // route with regex parameter
array(
'methods' => WP_REST_Server::READABLE, // GET
'callback' => 'my_plugin_get_item',
'permission_callback' => 'my_plugin_get_item_permissions_check',
'args' => array(
'id' => array(
'validate_callback' => function( $param ) {
return is_numeric( $param );
},
'sanitize_callback' => 'absint',
),
),
)
);
}
function my_plugin_get_item_permissions_check( WP_REST_Request $request ): bool {
return current_user_can( 'read' );
}
function my_plugin_get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$id = $request->get_param( 'id' );
$item = get_post( $id );
if ( ! $item ) {
return new WP_Error( 'not_found', 'Item not found.', array( 'status' => 404 ) );
}
return rest_ensure_response( array(
'id' => $item->ID,
'title' => $item->post_title,
) );
}
Always set a permission_callback. Setting it to __return_true is acceptable only for genuinely public read endpoints β never for write operations.
WordPress 6.9 Abilities API
WordPress 6.9 introduced the Abilities API as part of the broader βAI Building Blocks for WordPressβ initiative. This is the most significant new developer surface area in recent WordPress releases and is directly relevant to anyone building plugins that should be discoverable by AI agents, automation tools, or external orchestration systems. You can read more about it in our WordPress Abilities API guide.
An ability is a self-contained unit of functionality with defined inputs, outputs, permissions, and execution logic β essentially a machine-readable, validated, permission-checked function that can be invoked via PHP or via a standardized REST API namespace (wp-abilities/v1).
add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_ability_categories' );
function my_plugin_register_ability_categories(): void {
wp_register_ability_category(
'content-management',
array(
'label' => __( 'Content Management', 'my-plugin' ),
'description' => __( 'Abilities for managing and organizing content.', 'my-plugin' ),
)
);
}
add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
function my_plugin_register_abilities(): void {
wp_register_ability(
'my-plugin/get-post-count',
array(
'label' => __( 'Get Post Count', 'my-plugin' ),
'description' => __( 'Returns the number of published posts of a given type.', 'my-plugin' ),
'category' => 'content-management',
'input_schema' => array(
'type' => 'string',
'description' => __( 'The post type to count.', 'my-plugin' ),
'default' => 'post',
),
'output_schema' => array(
'type' => 'integer',
'description' => __( 'Number of published posts.', 'my-plugin' ),
),
'execute_callback' => 'my_plugin_get_post_count',
'permission_callback' => fn() => current_user_can( 'read' ),
'meta' => array( 'show_in_rest' => true ),
)
);
}
function my_plugin_get_post_count( string $post_type ): int {
$count = wp_count_posts( $post_type );
return (int) $count->publish;
}
// Backward compatibility with WordPress < 6.9
if ( function_exists( 'wp_register_ability' ) ) {
add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' );
}
Once registered with show_in_rest: true, the ability is accessible at POST /wp-json/wp-abilities/v1/abilities/my-plugin/get-post-count/run. The API validates inputs against your JSON Schema, checks the permission callback, executes the function, and returns the result as JSON β without you writing any REST route boilerplate.
This is meaningful for plugin authors because it creates a discoverable, standardized interface that AI agents can enumerate and invoke. Where today you might hand-document your pluginβs REST endpoints for integrators, abilities give you a machine-readable registry for free.
Internationalization (i18n)
Every user-facing string in your plugin must be wrapped in a translation function. This is required to submit to the WordPress plugin directory.
// Basic translation
__( 'Settings saved.', 'my-plugin' )
// With HTML context (use esc_html__ for output)
esc_html__( 'Are you sure?', 'my-plugin' )
// Singular/plural
sprintf(
_n( '%s item deleted.', '%s items deleted.', $count, 'my-plugin' ),
number_format_i18n( $count )
)
Load the text domain early:
add_action( 'plugins_loaded', function() {
load_plugin_textdomain( 'my-plugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
} );
Generate .pot files with WP-CLI: wp i18n make-pot . languages/my-plugin.pot.
Coding Standards and Testing
WordPress maintains its own coding standards (WPCS), enforced via PHP_CodeSniffer. Install with Composer:
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
./vendor/bin/phpcs --standard=WordPress my-plugin.php
For testing, the official approach is PHPUnit with the WordPress test suite. WP-CLI scaffolds tests with wp scaffold plugin-tests my-plugin. For modern projects, Pest PHP works well and is increasingly common in the community.
Local development environments with built-in test runners: wp-env (official, Docker-based), LocalWP, or Lando. All three support running PHPUnit against an isolated WordPress instance.
AI-Assisted Development with Claude Code and MCP
The most practical change to the WordPress plugin development workflow in 2026 is the availability of capable AI coding assistants that understand PHP, WordPress conventions, and the plugin API well enough to generate production-relevant code on the first try.
Claude Code and Cursor (both support the Model Context Protocol) can read your pluginβs source, suggest hook placements, generate boilerplate that follows WPCS standards, and catch common security omissions like missing nonce verification or unescaped output. These are genuinely useful time-savers for experienced developers β not just training wheels for beginners.
Where this becomes more interesting is when you connect the AI assistant to an actual WordPress test site via Easy MCP AI. Easy MCP AI is a free, open-source WordPress plugin that turns your self-hosted WordPress site into a remote MCP server β no Node.js proxy, no external service. Once installed, AI clients like Claude Code or Cursor can connect directly to the site and interact with it via natural language across 242 tools (96 core, 92 plugin-specific, 54 data integrations).
What this means in practice for plugin developers:
- Test against a real site without switching context. While working in your editor, you can ask Claude Code to verify that your pluginβs custom post type appears correctly, check that your REST endpoint returns the expected schema, or confirm that a capability check correctly restricts access to subscribers β all without opening a browser.
- Scaffold content for testing. Instead of manually creating test posts, users, and taxonomies, you can describe the fixture data you need and let the AI set it up via MCP tools in seconds.
- Iterate on Abilities API registrations. With an MCP-connected site running WordPress 6.9+, you can enumerate registered abilities in real time, spot missing schemas, and test permission callbacks without writing one-off debugging scripts.
Easy MCP AI hashes API tokens with SHA-256 before storage, authenticates AI clients via OAuth 2.1, and enforces per-tool capability scoping plus standard WordPress capability checks on every call. Everything stays on your server β thereβs no third-party SaaS intermediary in the loop.
The Abilities API and MCP are complementary layers. The Abilities API standardizes how your plugin exposes functionality within WordPress; MCP standardizes how AI clients connect to WordPress from outside. Together they give you a plugin that is both internally well-structured and externally AI-accessible.
Distributing on WordPress.org
To submit your plugin to the official directory:
- Create an account at wordpress.org and apply at https://wordpress.org/plugins/developers/add/
- The review team checks for security issues, licensing (must be GPL-compatible), and coding standards
- Once approved, you get an SVN repository; use
svn commitor the recommended workflow with WP-CLI Deploy or GitHub Actions - Tag releases under
tags/(e.g.,tags/1.0.0); trunk should always be current stable or development
A readme.txt file (in WordPress readme format, not GitHub Markdown) is required and controls how your plugin page looks on the directory.
Key Facts
- WordPress plugin header fields are required for WordPress to recognize and display the plugin.
Plugin Nameis mandatory; all others are optional but strongly recommended. add_action()andadd_filter()accept a priority argument (default 10); lower fires earlier.- Nonces expire after 12-24 hours by default, governed by the
nonce_lifefilter. Always verify them server-side β client-side verification is not security. - The
wp-abilities/v1REST namespace is available in WordPress 6.9+ when abilities are registered withshow_in_rest: true. - Easy MCP AI exposes 242 tools across your WordPress site to AI clients including Claude, ChatGPT, Cursor, n8n, and Claude Code β free and open-source, self-hosted, no Node.js required.
- WordPress coding standards are enforced at review time for plugin directory submissions. Run PHPCS locally to catch issues before submitting.
Conclusion
WordPress plugin development in 2026 follows the same foundational principles it always has β hooks, security, standards β but the ecosystem around it has matured significantly. The Abilities API gives you a first-class way to expose plugin functionality to AI agents and automation tools without hand-rolling REST endpoints. AI coding assistants paired with MCP-connected test sites reduce the distance between writing code and verifying it on a real WordPress install.
The most important thing to internalize is that a well-built plugin is not just one that works β it is one that sanitizes all input, escapes all output, verifies nonces and capabilities, respects WordPress coding standards, and handles failure gracefully. These are not overhead; they are the difference between a plugin that ships once and one that survives five years of WordPress updates.
If you are building plugins that should be accessible to AI agents, installing Easy MCP AI on your development site is one of the most practical steps you can take right now.
Get Easy MCP AI from the WordPress plugin directory
Official Sources
- WordPress Plugin Handbook β developer.wordpress.org
- WordPress Coding Standards β github.com/WordPress/WordPress-Coding-Standards
- REST API Handbook β developer.wordpress.org/rest-api
- Abilities API in WordPress 6.9 β make.wordpress.org
- Abilities API GitHub Repository β github.com/WordPress/abilities-api
- Plugin Security β developer.wordpress.org/plugins/security
- Internationalization β developer.wordpress.org/plugins/internationalization
- Easy MCP AI β wordpress.org/plugins/easy-mcp-ai