
After building over 30 custom WordPress plugins—from simple utility tools to complex e-commerce integrations—I've developed a reliable approach to plugin development that keeps code maintainable and secure. In this guide, I'll walk you through everything from your first plugin file to publishing on the WordPress.org repository.
Why Build Custom WordPress Plugins?
WordPress powers over 43% of all websites, and plugins are the primary way to extend its functionality. Building custom plugins lets you:
- Solve specific business problems that no existing plugin addresses
- Avoid plugin bloat by writing only the code you need
- Control quality and security instead of trusting third-party code
- Create commercial products for the WordPress marketplace
- Contribute to the community through open-source development
The WordPress plugin ecosystem has over 60,000 free plugins, yet businesses constantly need custom solutions that don't exist off the shelf.
WordPress Plugin Structure
Every WordPress plugin starts with a single PHP file containing a plugin header comment. Here's the minimum viable plugin:
Basic Plugin File
Create a new directory in wp-content/plugins/ and add your main plugin file:
<?php
/**
* Plugin Name: My Custom Plugin
* Plugin URI: https://yoursite.com/my-custom-plugin
* Description: A brief description of what the plugin does.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://yoursite.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
* Requires at least: 6.0
* Requires PHP: 8.0
*/
// Prevent direct file access
if (!defined('ABSPATH')) {
exit;
}
// Define plugin constants
define('MCP_VERSION', '1.0.0');
define('MCP_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MCP_PLUGIN_URL', plugin_dir_url(__FILE__));
Recommended Directory Structure
For anything beyond a trivial plugin, organize your files like this:
my-custom-plugin/
├── my-custom-plugin.php # Main plugin file
├── readme.txt # WordPress.org readme
├── uninstall.php # Cleanup on uninstall
├── includes/
│ ├── class-plugin-core.php # Main plugin class
│ ├── class-admin.php # Admin functionality
│ └── class-public.php # Frontend functionality
├── admin/
│ ├── css/
│ ├── js/
│ └── views/ # Admin page templates
├── public/
│ ├── css/
│ └── js/
├── languages/ # Translation files
└── templates/ # Public-facing templates
Understanding Hooks: Actions and Filters
Hooks are the backbone of WordPress plugin development. They let you inject your code into WordPress at specific points without modifying core files.
Actions
Actions let you execute code at specific points in the WordPress lifecycle:
// Add a custom message after each post
add_action('the_content', 'mcp_add_post_footer');
function mcp_add_post_footer($content) {
if (is_single() && is_main_query()) {
$footer = '<div class="post-footer">';
$footer .= '<p>Thanks for reading! Share this post with your network.</p>';
$footer .= '</div>';
$content .= $footer;
}
return $content;
}
Common actions you'll use frequently:
| Action Hook | When It Fires |
|---|---|
init |
After WordPress loads but before headers are sent |
admin_init |
On every admin page load |
wp_enqueue_scripts |
When enqueueing frontend scripts/styles |
admin_enqueue_scripts |
When enqueueing admin scripts/styles |
save_post |
When a post is saved or updated |
wp_ajax_{action} |
When handling an AJAX request (logged-in users) |
wp_ajax_nopriv_{action} |
When handling an AJAX request (non-logged-in users) |
Filters
Filters modify data before WordPress uses or displays it:
// Modify the excerpt length
add_filter('excerpt_length', 'mcp_custom_excerpt_length');
function mcp_custom_excerpt_length($length) {
return 30; // 30 words instead of default 55
}
// Add custom CSS classes to the body tag
add_filter('body_class', 'mcp_custom_body_classes');
function mcp_custom_body_classes($classes) {
if (is_page_template('templates/landing.php')) {
$classes[] = 'landing-page';
$classes[] = 'no-sidebar';
}
return $classes;
}
Priority and Accepted Arguments
Control execution order and the number of arguments your callback receives:
// Lower priority number = runs earlier (default is 10)
add_action('init', 'mcp_early_init', 5);
add_action('init', 'mcp_late_init', 20);
// Accept multiple arguments with the fourth parameter
add_filter('the_title', 'mcp_modify_title', 10, 2);
function mcp_modify_title($title, $post_id) {
$post = get_post($post_id);
if ($post && $post->post_type === 'product') {
return '🛒 ' . $title;
}
return $title;
}
Building an Admin Settings Page
Most plugins need a settings page. Here's how to create one properly using the WordPress Settings API:
Register the Menu and Settings
class MCP_Admin {
public function __construct() {
add_action('admin_menu', array($this, 'add_admin_menu'));
add_action('admin_init', array($this, 'register_settings'));
}
public function add_admin_menu() {
add_options_page(
'My Plugin Settings', // Page title
'My Plugin', // Menu title
'manage_options', // Capability required
'my-custom-plugin', // Menu slug
array($this, 'settings_page') // Callback
);
}
public function register_settings() {
register_setting(
'mcp_settings_group', // Option group
'mcp_settings', // Option name
array($this, 'sanitize_settings') // Sanitization callback
);
add_settings_section(
'mcp_general_section',
'General Settings',
array($this, 'section_description'),
'my-custom-plugin'
);
add_settings_field(
'mcp_api_key',
'API Key',
array($this, 'api_key_field'),
'my-custom-plugin',
'mcp_general_section'
);
add_settings_field(
'mcp_enable_feature',
'Enable Feature',
array($this, 'enable_feature_field'),
'my-custom-plugin',
'mcp_general_section'
);
}
public function sanitize_settings($input) {
$sanitized = array();
$sanitized['api_key'] = sanitize_text_field($input['api_key'] ?? '');
$sanitized['enable_feature'] = isset($input['enable_feature']) ? 1 : 0;
return $sanitized;
}
public function section_description() {
echo '<p>Configure your plugin settings below.</p>';
}
public function api_key_field() {
$options = get_option('mcp_settings');
$value = $options['api_key'] ?? '';
echo '<input type="text" name="mcp_settings[api_key]" value="' . esc_attr($value) . '" class="regular-text">';
}
public function enable_feature_field() {
$options = get_option('mcp_settings');
$checked = isset($options['enable_feature']) && $options['enable_feature'] ? 'checked' : '';
echo '<input type="checkbox" name="mcp_settings[enable_feature]" value="1" ' . $checked . '>';
}
public function 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('mcp_settings_group');
do_settings_sections('my-custom-plugin');
submit_button('Save Settings');
?>
</form>
</div>
<?php
}
}
new MCP_Admin();
Registering Custom Post Types
Custom post types are one of the most powerful WordPress features. Here's how to register one properly:
add_action('init', 'mcp_register_portfolio_post_type');
function mcp_register_portfolio_post_type() {
$labels = array(
'name' => 'Portfolio',
'singular_name' => 'Project',
'menu_name' => 'Portfolio',
'add_new' => 'Add New Project',
'add_new_item' => 'Add New Project',
'edit_item' => 'Edit Project',
'new_item' => 'New Project',
'view_item' => 'View Project',
'search_items' => 'Search Projects',
'not_found' => 'No projects found',
'not_found_in_trash' => 'No projects in trash',
);
$args = array(
'labels' => $labels,
'public' => true,
'publicly_queryable' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_rest' => true, // Enable Gutenberg editor
'query_var' => true,
'rewrite' => array('slug' => 'portfolio'),
'capability_type' => 'post',
'has_archive' => true,
'hierarchical' => false,
'menu_position' => 5,
'menu_icon' => 'dashicons-portfolio',
'supports' => array(
'title',
'editor',
'thumbnail',
'excerpt',
'custom-fields',
'revisions',
),
);
register_post_type('portfolio', $args);
}
Adding a Custom Taxonomy
add_action('init', 'mcp_register_project_type_taxonomy');
function mcp_register_project_type_taxonomy() {
$labels = array(
'name' => 'Project Types',
'singular_name' => 'Project Type',
'search_items' => 'Search Project Types',
'all_items' => 'All Project Types',
'parent_item' => 'Parent Project Type',
'parent_item_colon' => 'Parent Project Type:',
'edit_item' => 'Edit Project Type',
'update_item' => 'Update Project Type',
'add_new_item' => 'Add New Project Type',
'new_item_name' => 'New Project Type Name',
'menu_name' => 'Project Types',
);
register_taxonomy('project_type', 'portfolio', array(
'labels' => $labels,
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_rest' => true,
'show_admin_column' => true,
'rewrite' => array('slug' => 'project-type'),
));
}
Enqueueing Scripts and Styles
Never hardcode <script> or <link> tags. Use WordPress's enqueue system for proper dependency management:
add_action('wp_enqueue_scripts', 'mcp_enqueue_frontend_assets');
function mcp_enqueue_frontend_assets() {
// Enqueue a CSS file
wp_enqueue_style(
'mcp-styles',
MCP_PLUGIN_URL . 'public/css/styles.css',
array(), // Dependencies
MCP_VERSION
);
// Enqueue a JavaScript file with jQuery dependency
wp_enqueue_script(
'mcp-scripts',
MCP_PLUGIN_URL . 'public/js/scripts.js',
array('jquery'),
MCP_VERSION,
true // Load in footer
);
// Pass PHP data to JavaScript
wp_localize_script('mcp-scripts', 'mcpData', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mcp_nonce'),
'siteUrl' => home_url(),
'strings' => array(
'confirm' => __('Are you sure?', 'my-custom-plugin'),
'success' => __('Changes saved.', 'my-custom-plugin'),
),
));
}
// Admin-only scripts
add_action('admin_enqueue_scripts', 'mcp_enqueue_admin_assets');
function mcp_enqueue_admin_assets($hook) {
// Only load on our plugin's settings page
if ($hook !== 'settings_page_my-custom-plugin') {
return;
}
wp_enqueue_style(
'mcp-admin-styles',
MCP_PLUGIN_URL . 'admin/css/admin-styles.css',
array(),
MCP_VERSION
);
}
Handling AJAX Requests
AJAX lets your plugin communicate with the server without page reloads:
PHP Handler
// For logged-in users
add_action('wp_ajax_mcp_save_data', 'mcp_handle_save_data');
// For non-logged-in users (only if needed)
add_action('wp_ajax_nopriv_mcp_save_data', 'mcp_handle_save_data');
function mcp_handle_save_data() {
// Verify nonce for security
if (!check_ajax_referer('mcp_nonce', 'nonce', false)) {
wp_send_json_error(array(
'message' => 'Security check failed.'
), 403);
}
// Check user capabilities
if (!current_user_can('edit_posts')) {
wp_send_json_error(array(
'message' => 'Insufficient permissions.'
), 403);
}
// Sanitize input data
$title = sanitize_text_field($_POST['title'] ?? '');
$content = wp_kses_post($_POST['content'] ?? '');
if (empty($title)) {
wp_send_json_error(array(
'message' => 'Title is required.'
));
}
// Process the data
$post_id = wp_insert_post(array(
'post_title' => $title,
'post_content' => $content,
'post_status' => 'draft',
'post_type' => 'post',
));
if (is_wp_error($post_id)) {
wp_send_json_error(array(
'message' => $post_id->get_error_message()
));
}
wp_send_json_success(array(
'message' => 'Post created successfully.',
'post_id' => $post_id,
));
}
JavaScript AJAX Call
jQuery(document).ready(function($) {
$('#mcp-save-form').on('submit', function(e) {
e.preventDefault();
var $button = $(this).find('button[type="submit"]');
$button.prop('disabled', true).text('Saving...');
$.ajax({
url: mcpData.ajaxUrl,
type: 'POST',
data: {
action: 'mcp_save_data',
nonce: mcpData.nonce,
title: $('#post-title').val(),
content: $('#post-content').val()
},
success: function(response) {
if (response.success) {
alert(response.data.message);
} else {
alert('Error: ' + response.data.message);
}
},
error: function() {
alert('Request failed. Please try again.');
},
complete: function() {
$button.prop('disabled', false).text('Save');
}
});
});
});
Plugin Security Best Practices
Security is non-negotiable in plugin development. Follow these practices on every project:
Sanitize All Input
// Text fields
$name = sanitize_text_field($_POST['name']);
// Email addresses
$email = sanitize_email($_POST['email']);
// URLs
$url = esc_url_raw($_POST['website']);
// Rich text content (allows safe HTML)
$content = wp_kses_post($_POST['content']);
// Integer values
$count = absint($_POST['count']);
// File names
$filename = sanitize_file_name($_POST['filename']);
Escape All Output
// In HTML context
echo esc_html($user_input);
// In HTML attributes
echo '<input value="' . esc_attr($value) . '">';
// In URLs
echo '<a href="' . esc_url($link) . '">Click</a>';
// In JavaScript
echo '<script>var name = ' . wp_json_encode($name) . ';</script>';
// Allow specific HTML tags
echo wp_kses($content, array(
'a' => array('href' => array(), 'title' => array()),
'strong' => array(),
'em' => array(),
));
Use Nonces for Form Verification
// In your form template
wp_nonce_field('mcp_save_action', 'mcp_nonce');
// In your form handler
if (!isset($_POST['mcp_nonce']) || !wp_verify_nonce($_POST['mcp_nonce'], 'mcp_save_action')) {
wp_die('Security check failed.');
}
Check User Capabilities
// Before performing admin actions
if (!current_user_can('manage_options')) {
wp_die('You do not have permission to access this page.');
}
// Before editing a specific post
if (!current_user_can('edit_post', $post_id)) {
wp_die('You cannot edit this post.');
}
Plugin Activation, Deactivation, and Uninstall
Handle the full plugin lifecycle properly:
// Activation hook - runs once when plugin is activated
register_activation_hook(__FILE__, 'mcp_activate');
function mcp_activate() {
// Create custom database tables
global $wpdb;
$table_name = $wpdb->prefix . 'mcp_logs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE $table_name (
id bigint(20) NOT NULL AUTO_INCREMENT,
user_id bigint(20) NOT NULL,
action varchar(100) NOT NULL,
details text,
created_at datetime DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY action (action)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
// Set default options
add_option('mcp_settings', array(
'api_key' => '',
'enable_feature' => 0,
));
// Register custom post type and flush rewrite rules
mcp_register_portfolio_post_type();
flush_rewrite_rules();
}
// Deactivation hook - runs when plugin is deactivated
register_deactivation_hook(__FILE__, 'mcp_deactivate');
function mcp_deactivate() {
// Clear scheduled events
$timestamp = wp_next_scheduled('mcp_daily_cleanup');
if ($timestamp) {
wp_unschedule_event($timestamp, 'mcp_daily_cleanup');
}
flush_rewrite_rules();
}
Create an uninstall.php file in your plugin root to clean up when the plugin is deleted:
<?php
// uninstall.php
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
// Remove plugin options
delete_option('mcp_settings');
// Remove custom database tables
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}mcp_logs");
// Remove user meta
delete_metadata('user', 0, 'mcp_user_preference', '', true);
// Clear any cached data
wp_cache_flush();
Using OOP for Better Plugin Architecture
For maintainable plugins, use object-oriented architecture with an autoloader:
<?php
// Main plugin file: my-custom-plugin.php
if (!defined('ABSPATH')) {
exit;
}
// Autoloader
spl_autoload_register(function ($class) {
$prefix = 'MCP\\';
$base_dir = MCP_PLUGIN_DIR . 'includes/';
$len = strlen($prefix);
if (strncmp($prefix, $class, $len) !== 0) {
return;
}
$relative_class = substr($class, $len);
$file = $base_dir . 'class-' . strtolower(str_replace('\\', '-', $relative_class)) . '.php';
if (file_exists($file)) {
require $file;
}
});
// Initialize the plugin
add_action('plugins_loaded', function () {
MCP\Plugin::get_instance();
});
<?php
// includes/class-plugin.php
namespace MCP;
class Plugin {
private static $instance = null;
public static function get_instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->load_dependencies();
$this->define_admin_hooks();
$this->define_public_hooks();
}
private function load_dependencies() {
// Dependencies loaded via autoloader
}
private function define_admin_hooks() {
$admin = new Admin();
add_action('admin_menu', array($admin, 'add_menu_pages'));
add_action('admin_init', array($admin, 'register_settings'));
add_action('admin_enqueue_scripts', array($admin, 'enqueue_assets'));
}
private function define_public_hooks() {
$public = new Frontend();
add_action('wp_enqueue_scripts', array($public, 'enqueue_assets'));
add_filter('the_content', array($public, 'modify_content'));
}
}
Adding Shortcodes
Shortcodes let users embed plugin output anywhere in their content:
add_shortcode('mcp_contact_form', 'mcp_render_contact_form');
function mcp_render_contact_form($atts) {
$atts = shortcode_atts(array(
'title' => 'Contact Us',
'email' => get_option('admin_email'),
'button_text' => 'Send Message',
), $atts, 'mcp_contact_form');
ob_start();
?>
<div class="mcp-contact-form">
<h3><?php echo esc_html($atts['title']); ?></h3>
<form method="post" class="mcp-form">
<?php wp_nonce_field('mcp_contact', 'mcp_contact_nonce'); ?>
<p>
<label for="mcp-name">Name</label>
<input type="text" id="mcp-name" name="mcp_name" required>
</p>
<p>
<label for="mcp-email">Email</label>
<input type="email" id="mcp-email" name="mcp_email" required>
</p>
<p>
<label for="mcp-message">Message</label>
<textarea id="mcp-message" name="mcp_message" rows="5" required></textarea>
</p>
<p>
<button type="submit"><?php echo esc_html($atts['button_text']); ?></button>
</p>
</form>
</div>
<?php
return ob_get_clean();
}
Usage in WordPress editor: [mcp_contact_form title="Get in Touch" button_text="Submit"]
Scheduling Cron Jobs
Use WordPress cron for recurring tasks like sending reports, cleaning data, or syncing with external APIs:
// Schedule the event on plugin activation
register_activation_hook(__FILE__, 'mcp_schedule_events');
function mcp_schedule_events() {
if (!wp_next_scheduled('mcp_daily_cleanup')) {
wp_schedule_event(time(), 'daily', 'mcp_daily_cleanup');
}
}
// Define the cron callback
add_action('mcp_daily_cleanup', 'mcp_run_daily_cleanup');
function mcp_run_daily_cleanup() {
global $wpdb;
$table = $wpdb->prefix . 'mcp_logs';
// Delete logs older than 30 days
$wpdb->query(
$wpdb->prepare(
"DELETE FROM $table WHERE created_at < %s",
date('Y-m-d H:i:s', strtotime('-30 days'))
)
);
// Log the cleanup
error_log('MCP: Daily cleanup completed at ' . current_time('mysql'));
}
// Add custom cron schedule
add_filter('cron_schedules', 'mcp_add_cron_interval');
function mcp_add_cron_interval($schedules) {
$schedules['fifteen_minutes'] = array(
'interval' => 900,
'display' => 'Every Fifteen Minutes',
);
return $schedules;
}
Publishing to the WordPress Plugin Repository
Once your plugin is ready, submit it to WordPress.org:
Create a Proper readme.txt
=== My Custom Plugin ===
Contributors: yourusername
Tags: utility, custom post types, admin tools
Requires at least: 6.0
Tested up to: 6.7
Requires PHP: 8.0
Stable tag: 1.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Short description of the plugin (150 characters max).
== Description ==
Detailed description of your plugin. Explain the features,
use cases, and why someone should install it.
= Features =
* Feature one description
* Feature two description
* Feature three description
== Installation ==
1. Upload the plugin files to `/wp-content/plugins/my-custom-plugin`
2. Activate the plugin through the 'Plugins' screen in WordPress
3. Configure the plugin via Settings → My Plugin
== Frequently Asked Questions ==
= How do I configure the plugin? =
Navigate to Settings → My Plugin in your WordPress admin.
= Does this work with WooCommerce? =
Yes, the plugin is fully compatible with WooCommerce 8.0+.
== Changelog ==
= 1.0.0 =
* Initial release
== Upgrade Notice ==
= 1.0.0 =
Initial release of My Custom Plugin.
Submission Checklist
Before submitting, verify:
- No security vulnerabilities (sanitization, escaping, nonces)
- Plugin follows WordPress coding standards
- All strings are translatable using
__()and_e() - Plugin works with the latest WordPress version
- readme.txt is properly formatted
- No premium upsell or external service calls without disclosure
- GPL-compatible license
- No third-party code without proper attribution
Submit at WordPress Plugin Directory.
Debugging WordPress Plugins
Use these techniques to debug during development:
// Enable WordPress debug mode in wp-config.php
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true); // Log errors to wp-content/debug.log
define('WP_DEBUG_DISPLAY', false); // Don't show errors on screen
define('SCRIPT_DEBUG', true); // Use unminified core scripts
// Custom debug logging function
function mcp_log($message, $data = null) {
if (!defined('WP_DEBUG') || !WP_DEBUG) {
return;
}
$log_entry = '[MCP ' . current_time('Y-m-d H:i:s') . '] ' . $message;
if ($data !== null) {
$log_entry .= ' | Data: ' . print_r($data, true);
}
error_log($log_entry);
}
// Usage
mcp_log('Processing order', array('order_id' => 123, 'status' => 'pending'));
Install the Query Monitor plugin for detailed debugging of hooks, queries, HTTP requests, and more during development.
FAQ
How long does it take to learn WordPress plugin development?
If you know PHP basics, you can build a simple plugin in a few hours. Building production-ready plugins with proper architecture, security, and testing takes a few months of practice. Start with small utility plugins and gradually increase complexity.
Should I use OOP or procedural PHP for plugins?
For anything beyond a simple single-file plugin, use OOP. Classes provide better organization, avoid function name collisions, and make your code easier to test and maintain. The singleton pattern shown in this guide is a common starting point.
How do I update my plugin without breaking existing installations?
Use version checks and database migration patterns. Store a version number in the options table and compare it on plugins_loaded. Run upgrade routines when the stored version is lower than the current version. Never remove database columns or options without a deprecation period.
Can I use Composer in WordPress plugins?
Yes, but scope your dependencies to avoid conflicts with other plugins using the same libraries. Tools like PHP-Scoper or Strauss prefix namespaces to prevent autoloader collisions. This is especially important for popular libraries like Guzzle or Carbon.
What's the difference between a plugin and a must-use plugin?
Regular plugins can be activated and deactivated from the admin dashboard. Must-use plugins (mu-plugins) are placed in wp-content/mu-plugins/ and are always active—they cannot be deactivated without removing the file. Use mu-plugins for site-critical functionality that should never be accidentally disabled.
Building WordPress plugins is one of the most valuable skills in the WordPress ecosystem. Start with the basics covered in this guide, and you'll be creating professional-grade plugins in no time. Have questions? Feel free to reach out.

Fysal Yaqoob
Expert WordPress & Shopify Developer
Senior full-stack developer with 10+ years experience specializing in WordPress, Shopify, and headless CMS solutions. Delivering custom themes, plugins, e-commerce stores, and scalable web applications.
Practice: WordPress Developer Games
Take a break and level up your WordPress skills with our interactive developer games!


