Learning how to build a WordPress plugin is one of the most practical skills a developer can pick up. Plugins are the backbone of every WordPress site – they extend functionality, automate processes, and solve problems that themes alone cannot. Whether you need a custom contact form, a lead management dashboard, or a full exam system, the answer is always a plugin.

This wordpress plugin development tutorial walks you through the entire process, starting from a single PHP file and building up to custom post types, settings pages, and REST API endpoints. No fluff. Just working code you can follow along with.

At devash.pro, I build custom WordPress plugins for agencies and businesses every week. Products like Leadio (my lead CRM plugin) and Arcflow (our interactive workflow canvas) both started exactly the way this tutorial begins – with a plugin header and a clear plan.

Why Build a Custom WordPress Plugin?

Before diving into code, it is worth understanding when a custom wordpress plugin makes sense over an off-the-shelf solution.

Build custom when:

  • No existing plugin does exactly what you need
  • You need tight integration with your business workflow
  • Performance matters and bloated plugins slow your site
  • You want full ownership of the code with no vendor lock-in
  • Security is a priority and you cannot audit third-party code

Use existing plugins when:

  • The problem is common and well-solved (SEO, caching, forms)
  • You need the feature yesterday and budget is tight
  • The plugin is actively maintained with a strong track record

For agencies managing client sites, the line between “build” and “buy” comes up constantly. I wrote about this decision in detail in my guide on custom WordPress plugin development for agencies.

Setting Up Your WordPress Plugin Boilerplate

Every WordPress plugin starts with a single PHP file inside the wp-content/plugins/ directory. Here is the minimum viable structure for a wordpress plugin boilerplate.

File and Folder Structure

wp-content/plugins/my-first-plugin/
    my-first-plugin.php
    includes/
    assets/
        css/
        js/
    templates/
    readme.txt

Keep it simple at the start. You can always add folders as the plugin grows.

The Plugin Header

Create my-first-plugin.php and add the following header comment block. WordPress reads this to register and display your plugin in the admin dashboard.

<?php
/**
 * Plugin Name: My First Plugin
 * Plugin URI: https://example.com/my-first-plugin
 * Description: A starter plugin to learn WordPress plugin development.
 * Version: 1.0.0
 * Author: Your Name
 * Author URI: https://example.com
 * License: GPL-2.0+
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: my-first-plugin
 * Domain Path: /languages
 */

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

That ABSPATH check is not optional. It prevents anyone from loading your plugin file directly through a browser URL, which is a basic but important security measure.

Activation and Deactivation Hooks

WordPress provides hooks that fire when a plugin is activated or deactivated. Use these to set up database tables, default options, or flush rewrite rules.

// Activation hook
function mfp_activate() {
    // Add default options
    add_option( 'mfp_plugin_version', '1.0.0' );
    add_option( 'mfp_display_mode', 'standard' );

    // Flush rewrite rules for custom post types
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'mfp_activate' );

// Deactivation hook
function mfp_deactivate() {
    // Clean up scheduled events
    wp_clear_scheduled_hook( 'mfp_daily_cleanup' );

    // Flush rewrite rules
    flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'mfp_deactivate' );

A few things to note here. Always prefix your functions with a unique short prefix (we used mfp_ for “my first plugin”). This avoids naming collisions with other plugins. In production, use a PHP namespace or a class-based structure instead.

Creating Your First Shortcode

Shortcodes are the simplest way to output custom content anywhere on a WordPress site. Let us build one that displays a greeting message.

function mfp_greeting_shortcode( $atts ) {
    $atts = shortcode_atts(
        array(
            'name'  => 'visitor',
            'color' => '#0073aa',
        ),
        $atts,
        'mfp_greeting'
    );

    $name  = sanitize_text_field( $atts['name'] );
    $color = sanitize_hex_color( $atts['color'] );

    return sprintf(
        '<div class="mfp-greeting" style="color:%s;padding:20px;border-left:4px solid %s;">
            <p>Welcome, %s! Thanks for stopping by.</p>
        </div>',
        esc_attr( $color ),
        esc_attr( $color ),
        esc_html( $name )
    );
}
add_shortcode( 'mfp_greeting', 'mfp_greeting_shortcode' );

Usage in any post or page:

[mfp_greeting name="Sarah" color="#e74c3c"]

Notice the sanitization and escaping. Every piece of user input goes through sanitize_text_field() or sanitize_hex_color() before use, and every output uses esc_attr() or esc_html(). This is not extra work – it is the baseline for secure WordPress development.

Registering a Custom Post Type

Most serious plugins need their own content type. Custom post types let you store structured data – think “Leads” in a CRM, “Exams” in a testing platform, or “Properties” in a real estate plugin.

Here is how to register one:

function mfp_register_resource_post_type() {
    $labels = array(
        'name'               => 'Resources',
        'singular_name'      => 'Resource',
        'menu_name'          => 'Resources',
        'add_new'            => 'Add New Resource',
        'add_new_item'       => 'Add New Resource',
        'edit_item'          => 'Edit Resource',
        'new_item'           => 'New Resource',
        'view_item'          => 'View Resource',
        'search_items'       => 'Search Resources',
        'not_found'          => 'No resources found',
        'not_found_in_trash' => 'No resources found in trash',
    );

    $args = array(
        'labels'             => $labels,
        'public'             => true,
        'has_archive'        => true,
        'publicly_queryable' => true,
        'show_in_rest'       => true,
        'show_ui'            => true,
        'show_in_menu'       => true,
        'menu_position'      => 20,
        'menu_icon'          => 'dashicons-media-document',
        'supports'           => array( 'title', 'editor', 'thumbnail', 'excerpt', 'custom-fields' ),
        'rewrite'            => array( 'slug' => 'resources' ),
        'capability_type'    => 'post',
    );

    register_post_type( 'mfp_resource', $args );
}
add_action( 'init', 'mfp_register_resource_post_type' );

The show_in_rest parameter is important. Setting it to true makes your custom post type work with the block editor and also exposes it through the WordPress REST API automatically. Two wins for free.

This is exactly the approach I use when building marketplace plugins. Vendor profiles, product listings, and commission structures are all stored as custom post types with custom taxonomies for organisation.

Building a Settings Page

Nearly every plugin needs a settings page. WordPress provides the Settings API for this, which handles nonces, validation, and storage for you.

Step 1: Add the Menu Item

function mfp_add_settings_page() {
    add_options_page(
        'My First Plugin Settings',
        'My First Plugin',
        'manage_options',
        'mfp-settings',
        'mfp_render_settings_page'
    );
}
add_action( 'admin_menu', 'mfp_add_settings_page' );

Step 2: Register the Settings

function mfp_register_settings() {
    register_setting(
        'mfp_settings_group',
        'mfp_display_mode',
        array(
            'type'              => 'string',
            'sanitize_callback' => 'sanitize_text_field',
            'default'           => 'standard',
        )
    );

    add_settings_section(
        'mfp_general_section',
        'General Settings',
        function() {
            echo '<p>Configure the main plugin settings below.</p>';
        },
        'mfp-settings'
    );

    add_settings_field(
        'mfp_display_mode_field',
        'Display Mode',
        'mfp_display_mode_callback',
        'mfp-settings',
        'mfp_general_section'
    );
}
add_action( 'admin_init', 'mfp_register_settings' );

function mfp_display_mode_callback() {
    $value = get_option( 'mfp_display_mode', 'standard' );
    ?>
    <select name="mfp_display_mode">
        <option value="standard" <?php selected( $value, 'standard' ); ?>>Standard</option>
        <option value="compact" <?php selected( $value, 'compact' ); ?>>Compact</option>
        <option value="detailed" <?php selected( $value, 'detailed' ); ?>>Detailed</option>
    </select>
    <p class="description">Choose how the plugin content is displayed on the frontend.</p>
    <?php
}

Step 3: Render the Page

function mfp_render_settings_page() {
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form action="options.php" method="post">
            <?php
            settings_fields( 'mfp_settings_group' );
            do_settings_sections( 'mfp-settings' );
            submit_button( 'Save Settings' );
            ?>
        </form>
    </div>
    <?php
}

The Settings API handles CSRF protection (via nonces) and data storage (via the options table) automatically. You define the fields, WordPress handles the plumbing.

Adding a REST API Endpoint

Modern plugins often need to communicate with JavaScript frontends or external services. The WordPress REST API makes this straightforward.

function mfp_register_rest_routes() {
    register_rest_route( 'mfp/v1', '/resources', array(
        'methods'             => 'GET',
        'callback'            => 'mfp_get_resources',
        'permission_callback' => '__return_true',
    ) );

    register_rest_route( 'mfp/v1', '/resources', array(
        'methods'             => 'POST',
        'callback'            => 'mfp_create_resource',
        'permission_callback' => function() {
            return current_user_can( 'edit_posts' );
        },
    ) );
}
add_action( 'rest_api_init', 'mfp_register_rest_routes' );

function mfp_get_resources( $request ) {
    $args = array(
        'post_type'      => 'mfp_resource',
        'posts_per_page' => 10,
        'post_status'    => 'publish',
    );

    $query = new WP_Query( $args );
    $resources = array();

    foreach ( $query->posts as $post ) {
        $resources[] = array(
            'id'      => $post->ID,
            'title'   => $post->post_title,
            'content' => wp_strip_all_tags( $post->post_content ),
            'date'    => $post->post_date,
        );
    }

    return new WP_REST_Response( $resources, 200 );
}

Your endpoint is now available at yoursite.com/wp-json/mfp/v1/resources. The permission callback on the POST route ensures only logged-in users with editing capabilities can create new resources.

Enqueueing Scripts and Styles

Never hardcode <script> or <link> tags in WordPress. Use the proper enqueueing system so dependencies load in the right order and scripts can be combined or deferred by caching plugins.

function mfp_enqueue_assets() {
    wp_enqueue_style(
        'mfp-frontend',
        plugin_dir_url( __FILE__ ) . 'assets/css/frontend.css',
        array(),
        '1.0.0'
    );

    wp_enqueue_script(
        'mfp-frontend',
        plugin_dir_url( __FILE__ ) . 'assets/js/frontend.js',
        array( 'jquery' ),
        '1.0.0',
        true
    );

    wp_localize_script( 'mfp-frontend', 'mfpData', array(
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'mfp_nonce' ),
        'restUrl' => rest_url( 'mfp/v1/' ),
    ) );
}
add_action( 'wp_enqueue_scripts', 'mfp_enqueue_assets' );

The wp_localize_script() function is essential for passing server-side data (like the AJAX URL and a nonce) to your JavaScript. This is how your frontend code communicates securely with your plugin backend.

Security Best Practices

Security is not a feature you add later. Bake it in from the start.

  • Sanitize all input. Use sanitize_text_field(), sanitize_email(), absint(), and friends on every piece of incoming data
  • Escape all output. Use esc_html(), esc_attr(), esc_url(), and wp_kses() when rendering anything to the page
  • Verify nonces. Every form submission and AJAX request should check a nonce with wp_verify_nonce() or check_ajax_referer()
  • Check capabilities. Use current_user_can() before performing any action that modifies data
  • Use prepared statements. If you write raw SQL, always use $wpdb->prepare() to prevent SQL injection

These five rules cover the vast majority of WordPress plugin security. Skip any one of them and you are leaving a door open.

Testing and Debugging Your Plugin

Before shipping anything, test thoroughly.

  • Enable WP_DEBUG. Add define( 'WP_DEBUG', true ); and define( 'WP_DEBUG_LOG', true ); to wp-config.php during development
  • Check the error log. Find it at wp-content/debug.log
  • Test with default themes. Activate Twenty Twenty-Four to rule out theme conflicts
  • Deactivate other plugins. Isolate your plugin to confirm it works independently
  • Test multisite compatibility. If your plugin might run on a multisite network, test activation and functionality there too

For more complex plugins that integrate AI features or chatbot functionality, the debugging process has additional layers. This tutorial covered some of those techniques in my post about AI chatbots for WordPress.

From Tutorial to Production Plugin

This tutorial covered the fundamentals that every WordPress plugin is built on. To recap what you now know how to do:

  • Create a properly structured plugin with the correct file header
  • Use activation and deactivation hooks to manage plugin lifecycle
  • Build shortcodes that accept attributes and render safely
  • Register custom post types for structured data storage
  • Create settings pages using the WordPress Settings API
  • Add REST API endpoints for modern frontend communication
  • Enqueue scripts and styles the WordPress way
  • Follow security best practices from day one

The jump from tutorial plugin to production plugin involves additional concerns – database migrations, multisite support, internationalisation, automated testing, and update mechanisms. That is where experience matters.

When to Hire a Professional

Building a custom wordpress plugin is rewarding when you have the time and technical depth. But for business-critical functionality – lead management systems, payment integrations, multi-vendor marketplaces, or anything handling sensitive data – the cost of getting it wrong is high.

At devash.pro, I build custom plugins for agencies across London, Leeds, and globally. My portfolio includes tools like Leadio (a full lead CRM), Arcflow (workflow automation), and AI2WP (AI-powered page creation). Each started the same way this tutorial showed you – but scaled to handle real-world complexity.

If you need a custom WordPress plugin built properly, get in touch with devash.pro. I handle the architecture, security, and long-term maintenance so you can focus on running your agency.