14+ years building on WordPress / Replies in under 5 hours
WordPress 11 min read · Updated July 2026

How to Create a WordPress Plugin from Scratch: A Developer’s Guide

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
TL;DR

A WordPress plugin is a PHP package that hooks into WordPress via actions and filters. The minimum required piece is a main plugin file with the header comment block in `wp-content/plugins/your-plugin/`. Every PHP file needs `if ( ! defined( 'ABSPATH' ) ) { exit; }` at the top. The three security rules that apply to every form and AJAX handler: capability check first (`current_user_can()`), nonce verification second (`check_admin_referer()` or `wp_verify_nonce()`), then sanitise all input and escape all output separately. For larger plugins, a class-based structure (hooked via `plugins_loaded`) avoids global function collisions and makes the code testable.

A WordPress plugin is a PHP package that hooks into WordPress’s execution flow to add, modify, or remove functionality — without editing core files. Everything from WooCommerce to a simple shortcode is a plugin. This guide walks through building one from scratch in PHP: file structure, the hook system, a complete working example, the Options API, settings pages, and the security patterns that separate safe plugins from vulnerable ones.

For building a plugin with a React-based admin UI, see the React WordPress plugin guide. For a Node.js approach, see the Node.js WordPress plugin guide. This guide focuses on the PHP foundation that both of those build on.

File Structure and the Plugin Header

Every plugin lives in a subdirectory of wp-content/plugins/. Create the directory and main file:

wp-content/plugins/
└── my-custom-plugin/
    ├── my-custom-plugin.php    ← main file (required)
    ├── includes/
    │   └── class-my-plugin.php
    └── assets/
        ├── css/
        └── js/

The main plugin file must contain a plugin header comment block. WordPress reads this to display the plugin in the admin dashboard:

<?php
/**
 * Plugin Name:       My Custom Plugin
 * Plugin URI:        https://example.com/my-custom-plugin
 * Description:       A brief description of what this plugin does.
 * Version:           1.0.0
 * Requires at least: 6.0
 * Requires PHP:      8.0
 * Author:            Your Name
 * Author URI:        https://example.com
 * License:           GPL v2 or later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-custom-plugin
 * Domain Path:       /languages
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit; // Prevent direct file access.
}

The if ( ! defined( 'ABSPATH' ) ) { exit; } guard is mandatory in every PHP file in your plugin. ABSPATH is only defined when WordPress loads — a direct HTTP request to the file returns no such constant, so it exits before executing anything. Without this, someone can directly request your plugin file in a browser and potentially trigger code they shouldn’t.

The WordPress Hook System: Actions and Filters

WordPress’s plugin system is built on hooks — defined points in the execution flow where external code can attach. There are two types:

  • Actions (add_action / do_action): execute code at a specific point. No return value expected. Use them to do something — send an email, insert a row, output HTML.
  • Filters (add_filter / apply_filters): modify a value at a specific point. Must return the (modified) value. Use them to change something — alter post content, modify a query argument, transform output.
// Action: run my_function when WordPress initialises.
add_action( 'init', 'my_function' );

function my_function() {
    // Do something — no return needed.
    error_log( 'WordPress init fired.' );
}

// Filter: append text to every post.
add_filter( 'the_content', 'my_content_filter' );

function my_content_filter( $content ) {
    // Must return — echoing here would break the page.
    return $content . '<p>Thanks for reading.</p>';
}

The third and fourth parameters of add_action and add_filter are priority (default 10 — lower runs first) and accepted argument count (default 1). When a hook passes multiple arguments, declare how many you need:

// save_post passes $post_id, $post, $update — accept all three:
add_action( 'save_post', 'my_save_handler', 10, 3 );

function my_save_handler( $post_id, $post, $update ) {
    if ( $update ) {
        // Post is being updated, not created for the first time.
    }
}

A Complete Working Example: Shortcode Plugin

Shortcodes are a clean way to embed dynamic content in post editors. Here’s a complete, production-ready shortcode plugin — one file, all the patterns you’ll reuse in larger plugins:

<?php
/**
 * Plugin Name: Latest Posts Shortcode
 * Description: Outputs a recent posts list via [latest_posts count="5"].
 * Version:     1.0.0
 * Author:      Your Name
 * License:     GPL v2 or later
 * Text Domain: latest-posts-sc
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

// Register the shortcode on init.
add_action( 'init', 'lps_register_shortcode' );

function lps_register_shortcode() {
    add_shortcode( 'latest_posts', 'lps_render' );
}

/**
 * Render callback for [latest_posts count="5" category="news"].
 *
 * @param array  $atts    Shortcode attributes (merged with defaults).
 * @param string $content Enclosed content (unused here).
 * @return string         HTML output.
 */
function lps_render( $atts, $content = '' ) {
    // Merge provided atts with defaults and sanitise.
    $atts = shortcode_atts(
        array(
            'count'    => 5,
            'category' => '',
        ),
        $atts,
        'latest_posts'
    );

    $count    = absint( $atts['count'] );     // Ensures a positive integer.
    $category = sanitize_text_field( $atts['category'] );

    $args = array(
        'post_type'      => 'post',
        'posts_per_page' => $count,
        'post_status'    => 'publish',
        'no_found_rows'  => true,  // Skip COUNT(*) query — we only need rows.
    );

    if ( $category ) {
        $args['category_name'] = $category;
    }

    $query = new WP_Query( $args );

    if ( ! $query->have_posts() ) {
        return '<p>' . esc_html__( 'No posts found.', 'latest-posts-sc' ) . '</p>';
    }

    // Build output — use output buffering for clean template-style code.
    ob_start();
    ?>
    <ul class="lps-list">
    <?php while ( $query->have_posts() ) : $query->the_post(); ?>
        <li>
            <a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
            <span class="lps-date"><?php echo esc_html( get_the_date() ); ?></span>
        </li>
    <?php endwhile; wp_reset_postdata(); ?>
    </ul>
    <?php
    return ob_get_clean();
}

Key patterns in this example: shortcode_atts() for safe attribute merging, absint() and sanitize_text_field() for input sanitisation, no_found_rows for a cheaper query, wp_reset_postdata() after a custom WP_Query, and output buffering via ob_start() / ob_get_clean() instead of echoing directly (shortcode callbacks must return, not echo).

Enqueuing Scripts and Styles

Never use <script> or <link> tags directly in plugin output. Always use WordPress’s enqueue system — it handles dependency ordering, deduplication, and correct placement:

// Front-end assets:
add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_assets' );

function my_plugin_enqueue_assets() {
    // Only load on pages where the shortcode is actually used.
    // (Or use a flag set in the shortcode callback if you need that precision.)
    wp_enqueue_style(
        'my-plugin-style',                     // Handle (unique ID).
        plugin_dir_url( __FILE__ ) . 'assets/css/my-plugin.css',
        array(),                               // Dependencies.
        '1.0.0'                               // Version (cache busting).
    );

    wp_enqueue_script(
        'my-plugin-script',
        plugin_dir_url( __FILE__ ) . 'assets/js/my-plugin.js',
        array( 'jquery' ),                     // Depends on jQuery.
        '1.0.0',
        true                                   // true = load in footer.
    );
}

// Admin-only assets — scope by $hook to avoid loading on unrelated pages:
add_action( 'admin_enqueue_scripts', 'my_plugin_admin_assets' );

function my_plugin_admin_assets( $hook ) {
    // $hook is something like 'settings_page_my-plugin' or 'edit.php'.
    if ( 'settings_page_my-plugin' !== $hook ) {
        return;
    }
    wp_enqueue_script( 'my-plugin-admin', plugin_dir_url( __FILE__ ) . 'assets/js/admin.js', array(), '1.0.0', true );
}

For the complete guide to enqueue patterns including dependency chains, conditional loading, and inline data passing, see properly enqueuing scripts and styles in WordPress.

Storing Data: The Options API

For plugin settings and persistent data, use the Options API. It stores key-value pairs in the wp_options table:

// Save a value (auto-serialises arrays):
update_option( 'my_plugin_settings', array( 'enabled' => true, 'count' => 5 ) );

// Retrieve it (second arg = default if not set):
$settings = get_option( 'my_plugin_settings', array() );

// Clean up on uninstall:
delete_option( 'my_plugin_settings' );

For large datasets or relational data that doesn’t fit key-value storage, custom database tables are the right approach. See how to create custom database tables in WordPress for the dbDelta() pattern with proper column definitions.

Building a Settings Page

The Settings API registers options properly (with sanitisation callbacks) and outputs them via standard WordPress UI patterns:

// 1. Register the admin menu page.
add_action( 'admin_menu', 'my_plugin_add_menu' );

function my_plugin_add_menu() {
    add_options_page(
        'My Plugin Settings',   // Page title.
        'My Plugin',            // Menu label.
        'manage_options',       // Capability required.
        'my-plugin',            // Menu slug (matches $hook suffix above).
        'my_plugin_settings_page' // Callback that renders the page.
    );
}

// 2. Register settings and fields on admin_init.
add_action( 'admin_init', 'my_plugin_register_settings' );

function my_plugin_register_settings() {
    register_setting(
        'my_plugin_group',         // Option group.
        'my_plugin_settings',      // Option name.
        'my_plugin_sanitise'       // Sanitisation callback.
    );

    add_settings_section(
        'my_plugin_main',
        'General Settings',
        null,
        'my-plugin'
    );

    add_settings_field(
        'my_plugin_count',
        'Default Post Count',
        'my_plugin_count_field',   // Field render callback.
        'my-plugin',
        'my_plugin_main'
    );
}

// 3. Render the count field.
function my_plugin_count_field() {
    $opts  = get_option( 'my_plugin_settings', array() );
    $count = isset( $opts['count'] ) ? absint( $opts['count'] ) : 5;
    echo '<input type="number" name="my_plugin_settings[count]" value="' . esc_attr( $count ) . '" min="1" max="20">';
}

// 4. Sanitise before saving.
function my_plugin_sanitise( $input ) {
    $clean = array();
    if ( isset( $input['count'] ) ) {
        $clean['count'] = max( 1, min( 20, absint( $input['count'] ) ) );
    }
    return $clean;
}

// 5. Render the settings page.
function my_plugin_settings_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return; // Double-check capability even though the menu already requires it.
    }
    ?>
    <div class="wrap">
        <h1><?php esc_html_e( 'My Plugin Settings', 'my-custom-plugin' ); ?></h1>
        <form method="post" action="options.php">
            <?php
            settings_fields( 'my_plugin_group' );
            do_settings_sections( 'my-plugin' );
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

Security: Nonces, Capabilities, and Sanitisation

Plugin security has three pillars that apply every time you process user input or perform a privileged action:

1. Capability Checks

Always verify the user can perform the requested action before doing it:

if ( ! current_user_can( 'edit_posts' ) ) {
    wp_die( 'You do not have permission to do this.' );
}

Use the minimum capability required: edit_posts for content changes, manage_options for site settings, delete_users for user management. For custom permission models, see building custom WordPress user roles and capabilities.

2. Nonces (CSRF Protection)

A nonce is a single-use token tied to a specific action and the current user. It prevents cross-site request forgery — an attacker tricking an admin into submitting a form they didn’t intend to:

// In the form (generate):
wp_nonce_field( 'my_plugin_save', '_wpnonce_my_plugin' );

// In the handler (verify — die if invalid):
check_admin_referer( 'my_plugin_save', '_wpnonce_my_plugin' );

// For AJAX handlers — verify manually:
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'my_ajax_action' ) ) {
    wp_send_json_error( 'Invalid nonce.' );
}

3. Sanitise Input, Escape Output

Sanitise everything coming in; escape everything going out. These are separate operations with separate functions:

// Sanitise (clean input before storing):
$title    = sanitize_text_field( $_POST['title'] );         // Strips tags, trims whitespace.
$count    = absint( $_POST['count'] );                      // Positive integer only.
$content  = wp_kses_post( $_POST['content'] );              // Allows safe HTML (like TinyMCE output).
$url      = esc_url_raw( $_POST['redirect_url'] );          // Safe for storage, not output.
$slug     = sanitize_key( $_POST['slug'] );                 // Lowercase alphanumeric + hyphens.

// Escape (safe for output — never skip these):
echo esc_html( $title );       // Plain text in HTML.
echo esc_attr( $title );       // Inside an HTML attribute.
echo esc_url( $url );          // In href/src attributes.
echo wp_kses_post( $content ); // HTML with allowed tags.

For the complete sanitisation and validation decision tree, see sanitisation vs validation in WordPress.

Activation, Deactivation, and Uninstall Hooks

Three lifecycle hooks let your plugin respond to install/remove events. A critical gotcha: register_activation_hook() must be called from the main plugin file — the one WordPress activates. It doesn’t work from an included file:

// All three must be in the main plugin file (not an included file):

register_activation_hook( __FILE__, 'my_plugin_activate' );
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );
register_uninstall_hook( __FILE__, 'my_plugin_uninstall' );

function my_plugin_activate() {
    // Create custom DB tables, set default options, flush rewrite rules.
    if ( ! get_option( 'my_plugin_settings' ) ) {
        update_option( 'my_plugin_settings', array( 'count' => 5 ) );
    }
    flush_rewrite_rules(); // Required if you register custom post types or taxonomies.
}

function my_plugin_deactivate() {
    // Clean up transients, deregister cron jobs.
    wp_clear_scheduled_hook( 'my_plugin_cron_event' );
    flush_rewrite_rules();
}

function my_plugin_uninstall() {
    // Only runs when the user clicks "Delete" in the plugins screen.
    // Remove all plugin data: options, custom tables, user meta.
    delete_option( 'my_plugin_settings' );
}

Class-Based Structure for Larger Plugins

For anything beyond a simple utility, wrap your plugin in a class to avoid polluting the global function namespace:

<?php
// my-custom-plugin.php (main file — header + bootstrap only)

if ( ! defined( 'ABSPATH' ) ) { exit; }

require_once plugin_dir_path( __FILE__ ) . 'includes/class-my-plugin.php';

// Instantiate on plugins_loaded so all plugins/themes are available:
add_action( 'plugins_loaded', function() {
    new My_Custom_Plugin();
} );

// includes/class-my-plugin.php
class My_Custom_Plugin {

    public function __construct() {
        add_action( 'init',               array( $this, 'register_shortcodes' ) );
        add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
        add_action( 'admin_menu',         array( $this, 'add_admin_menu' ) );
    }

    public function register_shortcodes() {
        add_shortcode( 'my_shortcode', array( $this, 'render_shortcode' ) );
    }

    public function render_shortcode( $atts ) {
        // ...
    }

    public function enqueue_assets() {
        // ...
    }

    public function add_admin_menu() {
        // ...
    }
}

Using array( $this, 'method_name' ) as the callback instead of a global function string keeps all logic inside the class and makes the code testable via PHPUnit. For a complete testing approach, see unit testing WordPress plugins with PHPUnit.

Setting Up a Local Development Environment

Build and test plugins in a local environment before activating on any live site. The current standard options are LocalWP (simplest setup), Lando (Docker-based, closest to production), or plain Docker. For a comparison and setup guide, see setting up a modern local WordPress development environment.

What to Build Next

The PHP foundation covered here is the entry point. From here, the most common next steps are:

Frequently asked questions

Yes — PHP is the core language for WordPress plugin development. The minimum is understanding functions, arrays, variables, and basic string handling. WordPress's plugin API (add_action, add_filter, shortcode callbacks) is designed to be accessible to PHP beginners, but building secure, well-scoped plugins requires understanding sanitisation, output escaping, and database queries. For plugins with interactive admin UIs, React via @wordpress/scripts is used alongside PHP — PHP registers the admin page and the React component handles the frontend.

Actions execute code at a specific point in WordPress's execution without returning a value. Use them to do something — enqueue a script, insert a database row, send a notification. Filters modify a value at a specific point and must return the (possibly modified) value. Use them to change something — alter post content, adjust a query argument, transform output. The practical test: if WordPress gives you a value to modify and hand back, it's a filter. If WordPress is just signalling 'this moment happened and you can do something about it,' it's an action.

In a subdirectory of wp-content/plugins/ named after your plugin: wp-content/plugins/my-plugin/my-plugin.php. The main file must be in that subdirectory and contain the plugin header comment block — WordPress reads this to display the plugin in the admin screen. Single-file plugins (wp-content/plugins/my-plugin.php without a subdirectory) are valid but discouraged for anything beyond the simplest utility — they can't be listed on WordPress.org and don't have a clean location for additional files.

Add `if ( ! defined( 'ABSPATH' ) ) { exit; }` at the top of every PHP file in your plugin. ABSPATH is a constant WordPress defines during its bootstrap process — it's only set when WordPress is loading. A direct HTTP request to a plugin file (bypassing WordPress entirely) never sets ABSPATH, so the exit runs immediately. Without this guard, a direct request could trigger database queries, expose file paths, or execute code outside WordPress's security context.

A nonce (number used once) is a single-use cryptographic token tied to a specific action and the current user session. It prevents cross-site request forgery (CSRF): without nonce verification, an attacker could embed a hidden form on another site that submits to your plugin's handler, and a logged-in admin visiting that site would unknowingly trigger the action. Generate with wp_nonce_field('my-action') inside the form; verify with check_admin_referer('my-action') in the handler — this function dies automatically if the nonce is invalid or missing.

For anything beyond a simple one-hook utility, use a class. The class-based pattern wraps all your functions inside a namespace (the class name), which eliminates the risk of function name collisions with other plugins — a collision between two plugins' global functions produces a fatal error that's hard to debug. The standard pattern: define the class, instantiate it inside a closure hooked to plugins_loaded, and register all hooks inside the constructor using array( $this, 'method_name' ) callbacks. This structure also makes the plugin testable via PHPUnit, since methods can be called independently.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →