Home / Blog / WordPress Plugin Development: The Complete Guide (2026)
Guides 15 min read

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:

HookTypeWhen it fires
plugins_loadedActionAfter all active plugins are loaded
initActionAfter WordPress is set up, before headers sent
admin_menuActionRegisters admin menu pages
admin_enqueue_scriptsActionEnqueue assets in the admin
wp_enqueue_scriptsActionEnqueue assets on the front end
save_postActionAfter a post is saved
the_contentFilterFilters post content before display
wp_nav_menu_itemsFilterModifies 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 text
  • sanitize_email() β€” email addresses
  • absint() β€” positive integers
  • wp_kses_post() β€” HTML allowed in posts
  • sanitize_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 HTML
  • esc_attr() β€” HTML attribute values
  • esc_url() β€” URLs in href and src
  • esc_js() β€” inline JavaScript strings
  • wp_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:

  1. Create an account at wordpress.org and apply at https://wordpress.org/plugins/developers/add/
  2. The review team checks for security issues, licensing (must be GPL-compatible), and coding standards
  3. Once approved, you get an SVN repository; use svn commit or the recommended workflow with WP-CLI Deploy or GitHub Actions
  4. 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 Name is mandatory; all others are optional but strongly recommended.
  • add_action() and add_filter() accept a priority argument (default 10); lower fires earlier.
  • Nonces expire after 12-24 hours by default, governed by the nonce_life filter. Always verify them server-side β€” client-side verification is not security.
  • The wp-abilities/v1 REST namespace is available in WordPress 6.9+ when abilities are registered with show_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

Ready to control WordPress with AI?

Install Easy MCP AI on your site and connect Claude, Cursor, or any AI assistant in minutes.

Related Posts

Newsletter

The AI + WordPress space moves fast. Keep up.

New tools, workflow ideas, and product updates β€” be the first to know what's next.

No spam, unsubscribe anytime.