Build a Custom WordPress Plugin: Step by Step

The first time I “built a plugin,” it wasn’t a plugin at all — it was 200 lines of custom code jammed into my client’s functions.php file. Then they switched themes and every custom feature I’d built vanished overnight. That one incident taught me more about WordPress architecture than any tutorial did: if it’s not theme-dependent, it doesn’t belong in the theme. It belongs in a plugin.

Here’s the step-by-step process I still use today, whether I’m building something for a client or scratching my own itch on a side project.

Why a plugin, not a functions.php hack

Theme files are disposable. Clients redesign, agencies swap themes, page builders get replaced — and anything living in functions.php or a child theme goes with it. A plugin survives all of that because it’s decoupled from presentation. It also gives you a clean activation/deactivation lifecycle, which matters the moment you need to clean up custom database tables or options when someone uninstalls your code.

Rule of thumb I give junior devs on my team: if the code changes how the site *works*, it’s a plugin. If it changes how the site *looks*, it’s a theme. Contact form logic, custom post types, API integrations, cron jobs — all plugin territory.

There’s a third option worth knowing about: mu-plugins (“must-use”). These load automatically, can’t be deactivated from the dashboard, and sit in wp-content/mu-plugins/. I reach for these for things a client should never be able to accidentally switch off — security hardening, a critical API integration, or a fix that a previous developer’s plugin depends on. For everything else, a regular plugin is the right call because it keeps the activation toggle available when you need to debug a conflict.

The minimal file structure that actually works

You don’t need a framework to start. For a plugin doing one job well, this structure has served me on dozens of client builds:

my-plugin/
  my-plugin.php        (main file with the plugin header)
  includes/
    class-my-plugin.php
    class-my-plugin-admin.php
  assets/
    css/
    js/
  languages/
  readme.txt

The main file stays thin — it just loads the plugin header, defines constants, and requires the includes. Everything else lives in classes. This alone will save you from the “one giant 2,000-line file” trap that makes plugins impossible to hand off to another developer.

Writing the header and bootstrapping safely

The plugin header is what WordPress reads to list your plugin in the dashboard. It’s just a comment block:

<?php
/**
 * Plugin Name: My Plugin
 * Description: Does one specific thing well.
 * Version: 1.0.0
 * Author: Your Name
 * Text Domain: my-plugin
 */

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

define( 'MY_PLUGIN_VERSION', '1.0.0' );
define( 'MY_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );

require_once MY_PLUGIN_PATH . 'includes/class-my-plugin.php';

function my_plugin_init() {
    return My_Plugin::instance();
}
add_action( 'plugins_loaded', 'my_plugin_init' );

Two habits that have saved me from real incidents: always guard against direct file access with the ABSPATH check, and always prefix your function and class names (or better, wrap everything in a namespace). I’ve inherited codebases where two plugins both declared a function called get_data() and took down the whole site with a fatal error. A five-character prefix costs nothing and prevents that entirely.

Adding real functionality: hooks and a settings page

Once the skeleton is in place, everything else is hooks. Register a custom post type on init, add admin functionality on admin_menu, enqueue assets on wp_enqueue_scripts. A basic settings page pattern I reuse constantly:

add_action( 'admin_menu', function() {
    add_options_page(
        'My Plugin Settings',
        'My Plugin',
        'manage_options',
        'my-plugin',
        'my_plugin_render_settings'
    );
});

add_action( 'admin_init', function() {
    register_setting( 'my_plugin_group', 'my_plugin_api_key' );
});

Store configuration in the options table using register_setting() rather than hardcoding values — it’s what lets non-technical clients actually use what you built instead of pinging you every time a value changes. On a recent project this cut post-launch support tickets from the client by roughly half, simply because they could update an API key themselves instead of filing a ticket.

Testing, versioning, and shipping without breaking production

Before anything touches a live site:

  • Test activation and deactivation on a clean staging install — not just your dev environment, which usually has debug settings masking errors.
  • Turn on WP_DEBUG and WP_DEBUG_LOG and actually read the log, not just check that the page loads.
  • Bump the version number in the plugin header on every release — WordPress uses it to detect updates, and your future self will thank you when debugging which version is live.
  • Keep the plugin in its own git repo from day one, even for small internal tools. I’ve had to reconstruct plugin history from theme repo commits before, and it’s not fun.

If the plugin will run on multiple client sites, write a changelog in readme.txt from the very first version. It sounds like overhead when you’re moving fast, but six months later when a client asks “what changed in the last update,” you’ll have an answer instead of a git blame archaeology session.

One more thing I learned the expensive way: test your uninstall path too. Add an uninstall.php file (WordPress runs this automatically when a plugin is deleted, not just deactivated) to clean up any custom tables or options you created. Skip this and you leave orphaned data in the database on every site that ever tried your plugin — I once found four abandoned option rows from a plugin a previous agency had removed two years earlier, still being autoloaded on every page request.

The takeaway

A custom plugin doesn’t need to be complicated to be done right. Keep the main file thin, prefix everything, hook into WordPress instead of fighting it, and treat version control and staging tests as non-negotiable — not extras you’ll get to later. That’s the difference between a plugin that survives a theme change five years from now and one that quietly breaks the next time someone touches the site.

Faizan Khan
Faizan Khan

Technical PM & PHP developer — the manager who still ships code. 13 years turning “can we build this?” into “it’s live”.

Work with me →

Enough talk. Let’s launch.

One call. An honest scope, a real timeline, and weekly updates until it ships.