Mudassar Ali
← Blog

How to Build a Custom WordPress Plugin, Step by Step

· 3 min read

Most WordPress sites I inherit have the same problem: fifteen plugins doing a job that one small, purpose-built plugin could do better. Writing your own plugin sounds big, but the core of it is just a PHP file and a few hooks.

In this post we'll build a Testimonials plugin that registers a custom post type and shows testimonials anywhere with a shortcode.

1. The plugin file

Create a folder in wp-content/plugins/ called cd-testimonials, and inside it a file with the same name:

<?php
/**
 * Plugin Name: CD Testimonials
 * Description: Simple testimonials with a shortcode.
 * Version:     1.0.0
 * Author:      Mudassar Ali
 * Text Domain: cd-testimonials
 */

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

define( 'CDT_VERSION', '1.0.0' );
define( 'CDT_PATH', plugin_dir_path( __FILE__ ) );
define( 'CDT_URL', plugin_dir_url( __FILE__ ) );

That header comment is all WordPress needs to list the plugin under Plugins. The ABSPATH check stops anyone loading the file directly in the browser.

2. Register a custom post type

Hook into init and register the post type:

add_action( 'init', 'cdt_register_post_type' );

function cdt_register_post_type() {
    register_post_type( 'cd_testimonial', array(
        'labels'       => array(
            'name'          => __( 'Testimonials', 'cd-testimonials' ),
            'singular_name' => __( 'Testimonial', 'cd-testimonials' ),
        ),
        'public'       => false,
        'show_ui'      => true,
        'menu_icon'    => 'dashicons-format-quote',
        'supports'     => array( 'title', 'editor', 'thumbnail' ),
        'show_in_rest' => true, // Enables the block editor.
    ) );
}

public => false with show_ui => true gives editors an admin screen without creating front-end URLs you'd then have to keep out of Google.

3. Flush rewrite rules on activation

If your post type is public, flush permalinks once on activation, never on every page load:

register_activation_hook( __FILE__, function () {
    cdt_register_post_type();
    flush_rewrite_rules();
} );

register_deactivation_hook( __FILE__, 'flush_rewrite_rules' );

4. Add a shortcode

add_shortcode( 'testimonials', 'cdt_render_testimonials' );

function cdt_render_testimonials( $atts ) {
    $atts = shortcode_atts( array( 'count' => 3 ), $atts, 'testimonials' );

    $query = new WP_Query( array(
        'post_type'      => 'cd_testimonial',
        'posts_per_page' => absint( $atts['count'] ),
        'no_found_rows'  => true, // Skip pagination count query.
    ) );

    if ( ! $query->have_posts() ) {
        return '';
    }

    ob_start();
    echo '<div class="cdt-list">';
    while ( $query->have_posts() ) {
        $query->the_post();
        printf(
            '<blockquote class="cdt-item">%s<cite>%s</cite></blockquote>',
            wp_kses_post( get_the_content() ),
            esc_html( get_the_title() )
        );
    }
    echo '</div>';
    wp_reset_postdata();

    return ob_get_clean();
}

Two habits worth building from day one:

  • Shortcodes must return, not echo. That's why we use output buffering.
  • Escape on output. esc_html() for plain text, wp_kses_post() for content that may contain safe HTML.

Now [testimonials count="5"] works in any page.

5. Load CSS only where it's needed

Enqueuing styles on every page is one of the most common reasons WordPress sites get slow. Register the stylesheet, then enqueue it only when the shortcode actually runs:

add_action( 'wp_enqueue_scripts', function () {
    wp_register_style( 'cdt', CDT_URL . 'assets/cdt.css', array(), CDT_VERSION );
} );

// Inside cdt_render_testimonials(), before output:
wp_enqueue_style( 'cdt' );

6. Security checklist

Before shipping any plugin I check:

  1. Every form uses a nonce (wp_nonce_field() / check_admin_referer()).
  2. Every admin action checks capabilities (current_user_can( 'manage_options' )).
  3. Every input is sanitized (sanitize_text_field(), absint()…).
  4. Every output is escaped.
  5. Database queries use $wpdb->prepare().

7. Structure for when it grows

Once a plugin passes a few hundred lines, split it up:

cd-testimonials/
├── cd-testimonials.php   # header + bootstrap only
├── includes/
│   ├── class-post-type.php
│   ├── class-shortcode.php
│   └── class-settings.php
├── assets/
│   └── cdt.css
└── languages/

Wrapping up

That's a real, shippable plugin: a post type, a shortcode, conditional assets and proper escaping. From here you can add a settings page with the Settings API, a Gutenberg block, or ACF fields.

If you need a custom plugin built for your site, get in touch.