Written by 8:16 am Plugin Development Views: 3

How to Build Your First WordPress Plugin: A 2026 Developer Guide

Learn how to build a WordPress plugin from scratch in this comprehensive 2026 tutorial. We walk through plugin file structure, activation hooks, admin menus, the Settings API, shortcodes, enqueuing assets, internationalization, and security best practices while building a real Coming Soon page plugin.

How to build your first WordPress plugin - a 2026 developer guide covering plugin structure, hooks, settings API, and security

WordPress powers over 40% of the web, and plugins are the engine behind its flexibility. Whether you want to add a custom feature to your own site or build something for the WordPress.org repository, learning plugin development is one of the most valuable skills a PHP developer can pick up. This guide walks you through building a complete, functional WordPress plugin from scratch, covering every concept you need to ship real code in 2026.

By the end of this tutorial, you will have built a working “Coming Soon” page plugin that intercepts front-end requests, displays a maintenance page to visitors, and gives administrators full control through a settings panel. Along the way, you will learn the foundational patterns that power thousands of plugins in the WordPress ecosystem.


Understanding the WordPress Plugin Architecture

A WordPress plugin is, at its core, a PHP file (or collection of files) that WordPress loads during its boot sequence. WordPress scans the wp-content/plugins/ directory for PHP files containing a specific header comment, and any file with that header becomes available for activation in the admin dashboard.

Plugins hook into WordPress at defined extension points called actions and filters. Actions let you execute code at specific moments (when a post is saved, when the admin menu loads, when a page renders). Filters let you modify data as it passes through WordPress (changing post content, altering query parameters, transforming titles). This hook-based architecture means your plugin code runs only when relevant, without modifying WordPress core files.

The Plugin Developer Handbook on developer.wordpress.org is the authoritative reference for everything covered in this guide and beyond.

Setting Up Your Plugin File Structure

Start by creating a dedicated directory for your plugin inside wp-content/plugins/. A well-organized structure makes your plugin maintainable and ready for distribution.

wp-content/plugins/wpp-coming-soon/
├── wpp-coming-soon.php      # Main plugin file
├── readme.txt                # WordPress.org readme
├── includes/
│   └── class-settings.php    # Settings page logic
├── assets/
│   ├── css/
│   │   ├── admin.css         # Admin styles
│   │   └── coming-soon.css   # Front-end coming soon page styles
│   └── js/
│       └── admin.js          # Admin scripts
├── templates/
│   └── coming-soon.php       # Coming soon page template
└── languages/
    └── wpp-coming-soon.pot   # Translation template

This structure separates concerns cleanly. The main plugin file handles bootstrapping. The includes/ directory holds PHP classes. The assets/ directory organizes CSS and JavaScript. The templates/ directory stores any HTML templates. And the languages/ directory holds translation files for internationalization.

Writing the Plugin Header Comment

Every WordPress plugin must have a header comment in its main PHP file. This is how WordPress identifies and displays your plugin in the admin panel. Open wpp-coming-soon.php and add the following.

<?php
/**
 * Plugin Name: WPP Coming Soon
 * Plugin URI:  https://wppioneer.com/plugins/coming-soon
 * Description: Display a customizable coming soon page to visitors while you build your site. Admins and logged-in users bypass it.
 * Version:     1.0.0
 * Author:      Your Name
 * Author URI:  https://wppioneer.com
 * License:     GPL-2.0-or-later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: wpp-coming-soon
 * Domain Path: /languages
 * Requires at least: 6.4
 * Requires PHP: 8.0
 */

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

The Plugin Name field is the only required header, but including all fields is best practice. The Text Domain and Domain Path fields are essential for internationalization. The Requires at least and Requires PHP fields let WordPress warn users about compatibility before activation. If you are targeting PHP 8.0+, our article on PHP 8.x compatibility for plugin developers covers the breaking changes you need to handle. Note the security check at the bottom, which prevents anyone from loading your plugin file directly via its URL.

Plugin Constants and Bootstrap

Below the header comment, define constants that make your plugin portable. Hardcoding paths leads to brittle code that breaks when WordPress is installed in a subdirectory or when directory names change.

// Plugin constants
define( 'WPP_CS_VERSION', '1.0.0' );
define( 'WPP_CS_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'WPP_CS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'WPP_CS_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );

// Load dependencies
require_once WPP_CS_PLUGIN_DIR . 'includes/class-settings.php';

The plugin_dir_path() and plugin_dir_url() functions return the absolute filesystem path and URL respectively, handling trailing slashes and edge cases for you. Using __FILE__ ensures the paths are always relative to the main plugin file, regardless of how WordPress is installed.


Activation and Deactivation Hooks

WordPress provides dedicated hooks that fire when a user activates or deactivates your plugin. These are your opportunity to set default options, create database tables, or clean up after yourself.

/**
 * Runs on plugin activation.
 * Sets default options so the settings page works immediately.
 */
function wpp_cs_activate() {
    $defaults = array(
        'enabled'     => '0',
        'headline'    => __( 'Coming Soon', 'wpp-coming-soon' ),
        'message'     => __( 'We are working on something great. Stay tuned!', 'wpp-coming-soon' ),
        'bg_color'    => '#1e293b',
        'text_color'  => '#f8fafc',
        'bypass_role' => 'administrator',
    );

    // Only set defaults if options don't already exist (preserves user settings on reactivation)
    if ( false === get_option( 'wpp_cs_settings' ) ) {
        add_option( 'wpp_cs_settings', $defaults );
    }
}
register_activation_hook( __FILE__, 'wpp_cs_activate' );

/**
 * Runs on plugin deactivation.
 * Cleans up transients but preserves settings for potential reactivation.
 */
function wpp_cs_deactivate() {
    delete_transient( 'wpp_cs_status_cache' );
}
register_deactivation_hook( __FILE__, 'wpp_cs_deactivate' );

A key design decision here: we preserve the user’s settings on deactivation and only set defaults if no prior settings exist. This respects the user’s configuration if they temporarily deactivate and reactivate the plugin. If you want to remove all data on uninstall (not just deactivation), create an uninstall.php file in your plugin root. The Plugin Developer Handbook recommends this approach over using register_uninstall_hook() because the uninstall file runs without loading your entire plugin.

Creating an Admin Menu Page

Your plugin needs a settings interface. WordPress provides the add_menu_page() and add_submenu_page() functions to register custom admin pages. For a focused plugin like ours, a submenu page under the Settings menu keeps the admin sidebar clean.

/**
 * Register the admin menu page under Settings.
 */
function wpp_cs_add_admin_menu() {
    add_submenu_page(
        'options-general.php',                           // Parent slug (Settings menu)
        __( 'Coming Soon Settings', 'wpp-coming-soon' ), // Page title
        __( 'Coming Soon', 'wpp-coming-soon' ),          // Menu title
        'manage_options',                                 // Capability required
        'wpp-coming-soon',                                // Menu slug
        'wpp_cs_settings_page'                            // Callback function
    );
}
add_action( 'admin_menu', 'wpp_cs_add_admin_menu' );

The manage_options capability restricts access to administrators by default. This is a security boundary. Always use the most restrictive capability that makes sense for your feature. If you needed a top-level menu item instead, you would use add_menu_page() with an icon parameter (Dashicons name or a custom SVG) and a position parameter to control where it appears in the sidebar.

Implementing the Settings API

The WordPress Settings API provides a standardized way to create settings forms that handle validation, sanitization, nonce verification, and option storage automatically. It involves three registration functions: register_setting(), add_settings_section(), and add_settings_field().

/**
 * Register settings, sections, and fields.
 */
function wpp_cs_register_settings() {
    // Register the settings group with a sanitization callback
    register_setting(
        'wpp_cs_settings_group',  // Option group
        'wpp_cs_settings',        // Option name (stored in wp_options)
        array(
            'type'              => 'array',
            'sanitize_callback' => 'wpp_cs_sanitize_settings',
            'default'           => array(),
        )
    );

    // Add the main settings section
    add_settings_section(
        'wpp_cs_main_section',
        __( 'Page Settings', 'wpp-coming-soon' ),
        function() {
            echo '<p>' . esc_html__( 'Configure what visitors see when coming soon mode is active.', 'wpp-coming-soon' ) . '</p>';
        },
        'wpp-coming-soon'
    );

    // Enable/disable toggle
    add_settings_field(
        'wpp_cs_enabled',
        __( 'Enable Coming Soon', 'wpp-coming-soon' ),
        'wpp_cs_render_checkbox_field',
        'wpp-coming-soon',
        'wpp_cs_main_section',
        array( 'field' => 'enabled', 'label' => __( 'Activate coming soon mode', 'wpp-coming-soon' ) )
    );

    // Headline field
    add_settings_field(
        'wpp_cs_headline',
        __( 'Headline', 'wpp-coming-soon' ),
        'wpp_cs_render_text_field',
        'wpp-coming-soon',
        'wpp_cs_main_section',
        array( 'field' => 'headline', 'placeholder' => 'Coming Soon' )
    );

    // Message field
    add_settings_field(
        'wpp_cs_message',
        __( 'Message', 'wpp-coming-soon' ),
        'wpp_cs_render_textarea_field',
        'wpp-coming-soon',
        'wpp_cs_main_section',
        array( 'field' => 'message' )
    );
}
add_action( 'admin_init', 'wpp_cs_register_settings' );

Each field needs a render callback. Here are reusable render functions that read from the stored options array.

/**
 * Render a text input field.
 */
function wpp_cs_render_text_field( $args ) {
    $options = get_option( 'wpp_cs_settings', array() );
    $value   = isset( $options[ $args['field'] ] ) ? $options[ $args['field'] ] : '';
    printf(
        '<input type="text" name="wpp_cs_settings[%s]" value="%s" class="regular-text" placeholder="%s" />',
        esc_attr( $args['field'] ),
        esc_attr( $value ),
        esc_attr( $args['placeholder'] ?? '' )
    );
}

/**
 * Render a textarea field.
 */
function wpp_cs_render_textarea_field( $args ) {
    $options = get_option( 'wpp_cs_settings', array() );
    $value   = isset( $options[ $args['field'] ] ) ? $options[ $args['field'] ] : '';
    printf(
        '<textarea name="wpp_cs_settings[%s]" rows="4" class="large-text">%s</textarea>',
        esc_attr( $args['field'] ),
        esc_textarea( $value )
    );
}

/**
 * Render a checkbox field.
 */
function wpp_cs_render_checkbox_field( $args ) {
    $options = get_option( 'wpp_cs_settings', array() );
    $checked = isset( $options[ $args['field'] ] ) && '1' === $options[ $args['field'] ];
    printf(
        '<label><input type="checkbox" name="wpp_cs_settings[%s]" value="1" %s /> %s</label>',
        esc_attr( $args['field'] ),
        checked( $checked, true, false ),
        esc_html( $args['label'] )
    );
}

Sanitization: Validating and Cleaning User Input

Every piece of user input must be sanitized before it reaches the database. WordPress provides a suite of sanitization functions, and the Settings API calls your sanitization callback automatically on save. Never trust user input, even from administrators.

/**
 * Sanitize all settings before saving to the database.
 *
 * @param array $input Raw input from the settings form.
 * @return array Sanitized values.
 */
function wpp_cs_sanitize_settings( $input ) {
    $sanitized = array();

    $sanitized['enabled']    = isset( $input['enabled'] ) ? '1' : '0';
    $sanitized['headline']   = sanitize_text_field( $input['headline'] ?? '' );
    $sanitized['message']    = sanitize_textarea_field( $input['message'] ?? '' );
    $sanitized['bg_color']   = sanitize_hex_color( $input['bg_color'] ?? '#1e293b' );
    $sanitized['text_color'] = sanitize_hex_color( $input['text_color'] ?? '#f8fafc' );

    // Validate bypass role against real WordPress roles
    $valid_roles = array_keys( wp_roles()->roles );
    $sanitized['bypass_role'] = in_array( $input['bypass_role'] ?? '', $valid_roles, true )
        ? $input['bypass_role']
        : 'administrator';

    return $sanitized;
}

Notice how each field gets its own sanitization treatment. sanitize_text_field() strips tags and encodes special characters. sanitize_hex_color() ensures the color value is a valid hex code. The bypass role is validated against actual registered roles to prevent injection of arbitrary values. This defense-in-depth approach is essential for secure plugin development.


Rendering the Settings Page

Now wire up the settings page callback that was registered in the menu. The Settings API handles form processing, but you need to provide the HTML wrapper with the correct form action and nonce fields.

/**
 * Render the settings page HTML.
 */
function wpp_cs_settings_page() {
    // Security check - verify user capabilities
    if ( ! current_user_can( 'manage_options' ) ) {
        return;
    }
    ?>
    <div class="wrap">
        <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
        <form method="post" action="options.php">
            <?php
            settings_fields( 'wpp_cs_settings_group' );    // Nonce + hidden fields
            do_settings_sections( 'wpp-coming-soon' );     // Renders all sections/fields
            submit_button();                                // Submit button
            ?>
        </form>
    </div>
    <?php
}

The settings_fields() function outputs the nonce field and the option_page hidden input. The do_settings_sections() function renders all sections and their fields registered for the wpp-coming-soon page slug. When the form is submitted, WordPress validates the nonce, calls your sanitization callback, and stores the clean data in wp_options. You do not write any form-processing logic yourself.

Enqueueing Scripts and Styles

WordPress uses an enqueue system for loading CSS and JavaScript files. This system manages dependencies, prevents duplicate loading, and lets other plugins or themes dequeue your assets if needed. Never hardcode <script> or <link> tags directly in your plugin output.

/**
 * Enqueue admin styles and scripts only on our settings page.
 */
function wpp_cs_admin_enqueue( $hook ) {
    // Only load on our settings page - not every admin page
    if ( 'settings_page_wpp-coming-soon' !== $hook ) {
        return;
    }

    wp_enqueue_style(
        'wpp-cs-admin',                                    // Handle
        WPP_CS_PLUGIN_URL . 'assets/css/admin.css',        // Source URL
        array(),                                            // Dependencies
        WPP_CS_VERSION                                      // Version (cache busting)
    );

    wp_enqueue_script(
        'wpp-cs-admin',
        WPP_CS_PLUGIN_URL . 'assets/js/admin.js',
        array( 'jquery', 'wp-color-picker' ),               // Dependencies
        WPP_CS_VERSION,
        true                                                // Load in footer
    );

    // Pass PHP data to JavaScript safely
    wp_localize_script( 'wpp-cs-admin', 'wppCSAdmin', array(
        'ajaxUrl' => admin_url( 'admin-ajax.php' ),
        'nonce'   => wp_create_nonce( 'wpp_cs_admin_nonce' ),
    ));

    // Color picker styles
    wp_enqueue_style( 'wp-color-picker' );
}
add_action( 'admin_enqueue_scripts', 'wpp_cs_admin_enqueue' );

/**
 * Enqueue front-end styles for the coming soon page.
 */
function wpp_cs_frontend_enqueue() {
    $options = get_option( 'wpp_cs_settings', array() );
    if ( empty( $options['enabled'] ) || '1' !== $options['enabled'] ) {
        return;
    }

    wp_enqueue_style(
        'wpp-cs-frontend',
        WPP_CS_PLUGIN_URL . 'assets/css/coming-soon.css',
        array(),
        WPP_CS_VERSION
    );
}
add_action( 'wp_enqueue_scripts', 'wpp_cs_frontend_enqueue' );

Two critical practices here. First, the admin enqueue checks the $hook parameter to load assets only on our settings page, not across the entire admin. Loading scripts globally slows down every admin page and can cause conflicts. Second, wp_localize_script() safely passes PHP values (like AJAX URLs and nonces) to JavaScript, which is the correct way to make server data available to your client-side code.


Using WordPress Hooks: Actions and Filters in Practice

Hooks are the backbone of WordPress plugin development. Let us implement the core feature of our Coming Soon plugin using the template_redirect action hook, which fires before WordPress selects a template to render.

/**
 * Intercept front-end requests and show coming soon page.
 *
 * Uses template_redirect because it fires before any output,
 * allowing us to replace the entire page cleanly.
 */
function wpp_cs_template_redirect() {
    // Don't interfere with admin, AJAX, REST API, or cron
    if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || defined( 'REST_REQUEST' ) ) {
        return;
    }

    $options = get_option( 'wpp_cs_settings', array() );

    // Check if coming soon mode is enabled
    if ( empty( $options['enabled'] ) || '1' !== $options['enabled'] ) {
        return;
    }

    // Allow users with the bypass role to see the site normally
    $bypass_role = $options['bypass_role'] ?? 'administrator';
    if ( current_user_can( $bypass_role ) ) {
        return;
    }

    // Allow access to wp-login.php
    if ( false !== strpos( $_SERVER['REQUEST_URI'] ?? '', 'wp-login.php' ) ) {
        return;
    }

    // Set 503 status (tells search engines the site is temporarily unavailable)
    status_header( 503 );
    header( 'Retry-After: 3600' );

    // Load the coming soon template
    include WPP_CS_PLUGIN_DIR . 'templates/coming-soon.php';
    exit;
}
add_action( 'template_redirect', 'wpp_cs_template_redirect' );

This function demonstrates several important patterns. We check context first (admin, AJAX, cron, REST) to avoid interfering with essential WordPress operations. We use current_user_can() for capability-based access control rather than checking user roles directly. The 503 status code with a Retry-After header tells search engines that the site is temporarily unavailable, so they do not deindex your pages during maintenance.

Now let us add a practical filter example. We will prepend a warning to the admin page title when coming soon mode is active, so administrators always know the site status.

/**
 * Add a warning notice to the admin bar when coming soon mode is active.
 */
function wpp_cs_admin_bar_notice( $wp_admin_bar ) {
    $options = get_option( 'wpp_cs_settings', array() );
    if ( empty( $options['enabled'] ) || '1' !== $options['enabled'] ) {
        return;
    }

    $wp_admin_bar->add_node( array(
        'id'    => 'wpp-cs-notice',
        'title' => '&#9888; Coming Soon Mode Active',
        'href'  => admin_url( 'options-general.php?page=wpp-coming-soon' ),
        'meta'  => array( 'class' => 'wpp-cs-admin-bar-warning' ),
    ));
}
add_action( 'admin_bar_menu', 'wpp_cs_admin_bar_notice', 100 );

/**
 * Filter example: Append coming soon status to the site title in admin.
 */
function wpp_cs_modify_admin_title( $admin_title ) {
    $options = get_option( 'wpp_cs_settings', array() );
    if ( ! empty( $options['enabled'] ) && '1' === $options['enabled'] ) {
        $admin_title = '[Coming Soon] ' . $admin_title;
    }
    return $admin_title;
}
add_filter( 'admin_title', 'wpp_cs_modify_admin_title' );

The difference between the two is clear: add_action executes code at a hook point (adding a node to the admin bar), while add_filter receives a value, modifies it, and returns it (prepending text to the admin title). Filters must always return a value. Actions do not need to return anything.

Creating a Shortcode

Shortcodes let users embed dynamic content anywhere WordPress processes content: posts, pages, and text widgets. Let us create a shortcode that displays the coming soon status and an optional launch countdown.

/**
 * Register the [coming_soon_status] shortcode.
 *
 * Usage: [coming_soon_status show="countdown" launch_date="2026-04-01"]
 */
function wpp_cs_status_shortcode( $atts ) {
    $atts = shortcode_atts(
        array(
            'show'        => 'message',    // 'message' or 'countdown'
            'launch_date' => '',            // Date in Y-m-d format
        ),
        $atts,
        'coming_soon_status'
    );

    $options = get_option( 'wpp_cs_settings', array() );

    // Build output using output buffering (never echo inside shortcodes)
    ob_start();

    if ( 'countdown' === $atts['show'] && ! empty( $atts['launch_date'] ) ) {
        $launch = strtotime( $atts['launch_date'] );
        $now    = time();
        $diff   = max( 0, $launch - $now );
        $days   = floor( $diff / DAY_IN_SECONDS );

        printf(
            '<div class="wpp-cs-countdown"><span class="days">%d</span> %s</div>',
            intval( $days ),
            esc_html( _n( 'day until launch', 'days until launch', $days, 'wpp-coming-soon' ) )
        );
    } else {
        $status = ( ! empty( $options['enabled'] ) && '1' === $options['enabled'] )
            ? __( 'We are currently in coming soon mode.', 'wpp-coming-soon' )
            : __( 'The site is live!', 'wpp-coming-soon' );

        printf( '<div class="wpp-cs-status">%s</div>', esc_html( $status ) );
    }

    return ob_get_clean();
}
add_shortcode( 'coming_soon_status', 'wpp_cs_status_shortcode' );

There are two rules for shortcode callbacks that trip up many developers. First, shortcodes must return their output, not echo it. Using ob_start() and ob_get_clean() is a reliable pattern when your output involves conditionals and printf() calls. Second, always use shortcode_atts() to merge user-provided attributes with your defaults. This sanitizes the attribute names and provides consistent fallbacks.

Security Best Practices: Nonces, Sanitization, and Capability Checks

Security in WordPress plugins rests on three pillars: verifying intent (nonces), cleaning data (sanitization), and checking permissions (capability checks). We have already seen sanitization and capability checks in earlier sections. Let us look at nonce verification for a custom AJAX handler outside the Settings API.

/**
 * Handle a custom AJAX action with full security.
 */
function wpp_cs_handle_ajax_reset() {
    // 1. Verify the nonce (intent verification)
    if ( ! check_ajax_referer( 'wpp_cs_admin_nonce', 'nonce', false ) ) {
        wp_send_json_error( array( 'message' => 'Invalid security token.' ), 403 );
    }

    // 2. Check user capabilities (authorization)
    if ( ! current_user_can( 'manage_options' ) ) {
        wp_send_json_error( array( 'message' => 'Insufficient permissions.' ), 403 );
    }

    // 3. Sanitize any input data
    $confirm = sanitize_text_field( wp_unslash( $_POST['confirm'] ?? '' ) );

    if ( 'yes' !== $confirm ) {
        wp_send_json_error( array( 'message' => 'Confirmation required.' ) );
    }

    // 4. Perform the action
    delete_option( 'wpp_cs_settings' );
    wpp_cs_activate(); // Re-set defaults

    wp_send_json_success( array( 'message' => 'Settings reset to defaults.' ) );
}
add_action( 'wp_ajax_wpp_cs_reset', 'wpp_cs_handle_ajax_reset' );
  • Nonce verification (check_ajax_referer() or wp_verify_nonce()) confirms the request originated from your form, not a forged cross-site request.
  • Capability check (current_user_can()) verifies the user has the right permissions. Always check this even if the page is only accessible to admins. Defense in depth matters.
  • Input sanitization (sanitize_text_field(), wp_unslash()) strips dangerous characters and normalizes input before you use it in any logic.
  • Output escaping (esc_html(), esc_attr(), esc_url()) should be applied when rendering data, not when storing it. Escape late, sanitize early.

Internationalization: Making Your Plugin Translation-Ready

If you plan to distribute your plugin, internationalization (i18n) is essential. WordPress has built-in support for translatable strings through a gettext-based system. First, load the plugin text domain so WordPress can find your translation files.

/**
 * Load plugin text domain for translations.
 */
function wpp_cs_load_textdomain() {
    load_plugin_textdomain(
        'wpp-coming-soon',
        false,
        dirname( WPP_CS_PLUGIN_BASENAME ) . '/languages/'
    );
}
add_action( 'plugins_loaded', 'wpp_cs_load_textdomain' );

Then wrap every user-facing string with the appropriate translation function. The text domain must match what you declared in the plugin header.

  • __( 'String', 'wpp-coming-soon' ) returns a translated string for use in PHP code.
  • _e( 'String', 'wpp-coming-soon' ) echoes a translated string directly (shorthand for echo __( ... )).
  • esc_html__( 'String', 'wpp-coming-soon' ) returns a translated and HTML-escaped string.
  • _n( 'singular', 'plural', $count, 'wpp-coming-soon' ) handles plural forms based on a count.
  • sprintf( __( 'Hello, %s', 'wpp-coming-soon' ), $name ) for strings with dynamic values. Never concatenate translated strings.

As of WordPress 6.4+, the recommended approach for plugins hosted on WordPress.org is to let the GlotPress translation system handle .po/.mo files automatically. You generate a .pot file using WP-CLI (wp i18n make-pot . languages/wpp-coming-soon.pot) and upload it with your plugin. Community translators do the rest.

The Coming Soon Template

Let us build the front-end template that visitors will see. Create templates/coming-soon.php in your plugin directory.

<?php
/**
 * Coming Soon page template.
 *
 * @package WPP_Coming_Soon
 */

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

$options    = get_option( 'wpp_cs_settings', array() );
$headline   = esc_html( $options['headline'] ?? 'Coming Soon' );
$message    = esc_html( $options['message'] ?? '' );
$bg_color   = esc_attr( $options['bg_color'] ?? '#1e293b' );
$text_color = esc_attr( $options['text_color'] ?? '#f8fafc' );
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo( 'charset' ); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="robots" content="noindex, nofollow">
    <title><?php echo esc_html( $headline . ' - ' . get_bloginfo( 'name' ) ); ?></title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background-color: <?php echo $bg_color; ?>;
            color: <?php echo $text_color; ?>;
            display: flex;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            text-align: center;
            padding: 2rem;
        }
        .wpp-cs-container { max-width: 600px; }
        h1 { font-size: clamp( 2rem, 5vw, 3.5rem ); margin-bottom: 1rem; }
        p { font-size: 1.125rem; opacity: 0.85; line-height: 1.7; }
    </style>
    <?php wp_head(); ?>
</head>
<body>
    <div class="wpp-cs-container">
        <h1><?php echo $headline; ?></h1>
        <p><?php echo $message; ?></p>
    </div>
    <?php wp_footer(); ?>
</body>
</html>

This template is a standalone HTML page. It includes wp_head() and wp_footer() to allow other plugins and analytics tools to inject their scripts. The noindex, nofollow meta tag prevents search engines from indexing the maintenance page. Responsive typography via clamp() ensures the heading scales from mobile to desktop without breakpoint-based media queries.

Saving and Retrieving Options

Our plugin stores all settings in a single serialized array under one wp_options row. This is the recommended approach for plugins with multiple related settings. It reduces database queries (one get_option() call instead of six) and keeps the options table clean.

// Retrieve all settings with a fallback
$options = get_option( 'wpp_cs_settings', array() );

// Access individual settings with null coalescing
$headline = $options['headline'] ?? 'Coming Soon';
$enabled  = ( $options['enabled'] ?? '0' ) === '1';

// Update a single setting within the array
$options['enabled'] = '1';
update_option( 'wpp_cs_settings', $options );

// Delete the entire option (usually in uninstall.php)
delete_option( 'wpp_cs_settings' );

For larger plugins, consider using a helper class that wraps get_option() with type casting and default values. However, for plugins of this scope, direct access with null coalescing works well and keeps the code straightforward.

Building the readme.txt for WordPress.org

If you plan to submit your plugin to the WordPress.org repository, a properly formatted readme.txt is mandatory. It uses a markdown-like syntax specific to WordPress.

=== WPP Coming Soon ===
Contributors: yourwporgusername
Donate link: https://wppioneer.com/donate
Tags: coming soon, maintenance mode, under construction, launch page
Requires at least: 6.4
Tested up to: 6.7
Stable tag: 1.0.0
Requires PHP: 8.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Display a customizable coming soon page while you build your WordPress site.

== Description ==

WPP Coming Soon lets you put your site into maintenance mode with a single click.
Visitors see a clean, customizable coming soon page while administrators and
logged-in users can browse the site normally.

**Features:**

* One-click enable/disable
* Custom headline, message, and colors
* Role-based bypass (admin, editor, etc.)
* SEO-friendly 503 status code
* Admin bar indicator when active
* [coming_soon_status] shortcode
* Translation-ready

== Installation ==

1. Upload wpp-coming-soon to /wp-content/plugins/
2. Activate through Plugins menu
3. Go to Settings > Coming Soon to configure

== Changelog ==

= 1.0.0 =
* Initial release

The Stable tag field must match the version in your plugin header. The Tested up to field should reflect the latest WordPress version you have actually tested against. Update this with every WordPress major release to keep your plugin’s directory listing current.

Testing and Debugging Your Plugin

Before shipping, enable WordPress debug mode in wp-config.php and test thoroughly. For a deeper dive into debugging workflows, see our guide on debugging WordPress with professional tools and techniques.

// Add to wp-config.php during development
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );     // Logs errors to wp-content/debug.log
define( 'WP_DEBUG_DISPLAY', false ); // Don't show errors on screen
define( 'SCRIPT_DEBUG', true );      // Load unminified core scripts

Build a testing checklist for your plugin before any release.

  1. Activate the plugin. Verify default settings are created in the database.
  2. Enable coming soon mode. Open the site in an incognito window to confirm the coming soon page appears.
  3. Verify that the admin bar shows the warning indicator.
  4. Log out and confirm the coming soon page displays. Log back in and confirm the site loads normally.
  5. Change colors, headline, and message. Verify they render correctly.
  6. Test the shortcode in a post: [coming_soon_status] and [coming_soon_status show="countdown" launch_date="2026-06-01"].
  7. Deactivate the plugin. Verify the front-end loads normally.
  8. Reactivate and confirm settings were preserved.
  9. Delete the plugin and confirm wpp_cs_settings is removed from wp_options (if you created uninstall.php).
  10. Run wp plugin check wpp-coming-soon with the Plugin Check plugin for automated standards validation.

What to Build Next

You now have a fully functional WordPress plugin that demonstrates all the fundamental development patterns: hooks, settings, menus, shortcodes, enqueuing, security, and internationalization. Here are logical next steps to level up your plugin development skills.

  • Custom post types and taxonomies for managing structured data beyond posts and pages using register_post_type() and register_taxonomy(). Our guide on understanding pages, posts, and custom post types covers when each content type is the right choice.
  • REST API endpoints to build headless or JavaScript-driven features using register_rest_route().
  • Block editor integration with custom Gutenberg blocks using the @wordpress/scripts build tool and register_block_type().
  • Custom database tables via dbDelta() for data that does not fit the post/meta model.
  • WP-CLI commands to make your plugin controllable from the command line with WP_CLI::add_command().
  • Automated testing with the WordPress test suite and PHPUnit, using wp scaffold plugin-tests.

The complete source code for the WPP Coming Soon plugin built in this tutorial is structured to serve as a reference for your future projects. Every pattern demonstrated here scales to larger, more sophisticated plugins. The WordPress Plugin Developer Handbook remains the definitive reference as you continue building.

Visited 3 times, 1 visit(s) today

Last modified: March 25, 2026

Close