A WordPress plugin is a PHP package that hooks into WordPress’s execution flow to add, modify, or remove functionality — without editing core files. Everything from WooCommerce to a simple shortcode is a plugin. This guide walks through building one from scratch in PHP: file structure, the hook system, a complete working example, the Options API, settings pages, and the security patterns that separate safe plugins from vulnerable ones.
For building a plugin with a React-based admin UI, see the React WordPress plugin guide. For a Node.js approach, see the Node.js WordPress plugin guide. This guide focuses on the PHP foundation that both of those build on.
File Structure and the Plugin Header
Every plugin lives in a subdirectory of wp-content/plugins/. Create the directory and main file:
wp-content/plugins/
└── my-custom-plugin/
├── my-custom-plugin.php ← main file (required)
├── includes/
│ └── class-my-plugin.php
└── assets/
├── css/
└── js/
The main plugin file must contain a plugin header comment block. WordPress reads this to display the plugin in the admin dashboard:
<?php
/**
* Plugin Name: My Custom Plugin
* Plugin URI: https://example.com/my-custom-plugin
* Description: A brief description of what this plugin does.
* Version: 1.0.0
* Requires at least: 6.0
* Requires PHP: 8.0
* Author: Your Name
* Author URI: https://example.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
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Prevent direct file access.
}
The if ( ! defined( 'ABSPATH' ) ) { exit; } guard is mandatory in every PHP file in your plugin. ABSPATH is only defined when WordPress loads — a direct HTTP request to the file returns no such constant, so it exits before executing anything. Without this, someone can directly request your plugin file in a browser and potentially trigger code they shouldn’t.
The WordPress Hook System: Actions and Filters
WordPress’s plugin system is built on hooks — defined points in the execution flow where external code can attach. There are two types:
- Actions (
add_action/do_action): execute code at a specific point. No return value expected. Use them to do something — send an email, insert a row, output HTML. - Filters (
add_filter/apply_filters): modify a value at a specific point. Must return the (modified) value. Use them to change something — alter post content, modify a query argument, transform output.
// Action: run my_function when WordPress initialises.
add_action( 'init', 'my_function' );
function my_function() {
// Do something — no return needed.
error_log( 'WordPress init fired.' );
}
// Filter: append text to every post.
add_filter( 'the_content', 'my_content_filter' );
function my_content_filter( $content ) {
// Must return — echoing here would break the page.
return $content . '<p>Thanks for reading.</p>';
}
The third and fourth parameters of add_action and add_filter are priority (default 10 — lower runs first) and accepted argument count (default 1). When a hook passes multiple arguments, declare how many you need:
// save_post passes $post_id, $post, $update — accept all three:
add_action( 'save_post', 'my_save_handler', 10, 3 );
function my_save_handler( $post_id, $post, $update ) {
if ( $update ) {
// Post is being updated, not created for the first time.
}
}
A Complete Working Example: Shortcode Plugin
Shortcodes are a clean way to embed dynamic content in post editors. Here’s a complete, production-ready shortcode plugin — one file, all the patterns you’ll reuse in larger plugins:
<?php
/**
* Plugin Name: Latest Posts Shortcode
* Description: Outputs a recent posts list via [latest_posts count="5"].
* Version: 1.0.0
* Author: Your Name
* License: GPL v2 or later
* Text Domain: latest-posts-sc
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Register the shortcode on init.
add_action( 'init', 'lps_register_shortcode' );
function lps_register_shortcode() {
add_shortcode( 'latest_posts', 'lps_render' );
}
/**
* Render callback for [latest_posts count="5" category="news"].
*
* @param array $atts Shortcode attributes (merged with defaults).
* @param string $content Enclosed content (unused here).
* @return string HTML output.
*/
function lps_render( $atts, $content = '' ) {
// Merge provided atts with defaults and sanitise.
$atts = shortcode_atts(
array(
'count' => 5,
'category' => '',
),
$atts,
'latest_posts'
);
$count = absint( $atts['count'] ); // Ensures a positive integer.
$category = sanitize_text_field( $atts['category'] );
$args = array(
'post_type' => 'post',
'posts_per_page' => $count,
'post_status' => 'publish',
'no_found_rows' => true, // Skip COUNT(*) query — we only need rows.
);
if ( $category ) {
$args['category_name'] = $category;
}
$query = new WP_Query( $args );
if ( ! $query->have_posts() ) {
return '<p>' . esc_html__( 'No posts found.', 'latest-posts-sc' ) . '</p>';
}
// Build output — use output buffering for clean template-style code.
ob_start();
?>
<ul class="lps-list">
<?php while ( $query->have_posts() ) : $query->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
<span class="lps-date"><?php echo esc_html( get_the_date() ); ?></span>
</li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
<?php
return ob_get_clean();
}
Key patterns in this example: shortcode_atts() for safe attribute merging, absint() and sanitize_text_field() for input sanitisation, no_found_rows for a cheaper query, wp_reset_postdata() after a custom WP_Query, and output buffering via ob_start() / ob_get_clean() instead of echoing directly (shortcode callbacks must return, not echo).
Enqueuing Scripts and Styles
Never use <script> or <link> tags directly in plugin output. Always use WordPress’s enqueue system — it handles dependency ordering, deduplication, and correct placement:
// Front-end assets:
add_action( 'wp_enqueue_scripts', 'my_plugin_enqueue_assets' );
function my_plugin_enqueue_assets() {
// Only load on pages where the shortcode is actually used.
// (Or use a flag set in the shortcode callback if you need that precision.)
wp_enqueue_style(
'my-plugin-style', // Handle (unique ID).
plugin_dir_url( __FILE__ ) . 'assets/css/my-plugin.css',
array(), // Dependencies.
'1.0.0' // Version (cache busting).
);
wp_enqueue_script(
'my-plugin-script',
plugin_dir_url( __FILE__ ) . 'assets/js/my-plugin.js',
array( 'jquery' ), // Depends on jQuery.
'1.0.0',
true // true = load in footer.
);
}
// Admin-only assets — scope by $hook to avoid loading on unrelated pages:
add_action( 'admin_enqueue_scripts', 'my_plugin_admin_assets' );
function my_plugin_admin_assets( $hook ) {
// $hook is something like 'settings_page_my-plugin' or 'edit.php'.
if ( 'settings_page_my-plugin' !== $hook ) {
return;
}
wp_enqueue_script( 'my-plugin-admin', plugin_dir_url( __FILE__ ) . 'assets/js/admin.js', array(), '1.0.0', true );
}
For the complete guide to enqueue patterns including dependency chains, conditional loading, and inline data passing, see properly enqueuing scripts and styles in WordPress.
Storing Data: The Options API
For plugin settings and persistent data, use the Options API. It stores key-value pairs in the wp_options table:
// Save a value (auto-serialises arrays):
update_option( 'my_plugin_settings', array( 'enabled' => true, 'count' => 5 ) );
// Retrieve it (second arg = default if not set):
$settings = get_option( 'my_plugin_settings', array() );
// Clean up on uninstall:
delete_option( 'my_plugin_settings' );
For large datasets or relational data that doesn’t fit key-value storage, custom database tables are the right approach. See how to create custom database tables in WordPress for the dbDelta() pattern with proper column definitions.
Building a Settings Page
The Settings API registers options properly (with sanitisation callbacks) and outputs them via standard WordPress UI patterns:
// 1. Register the admin menu page.
add_action( 'admin_menu', 'my_plugin_add_menu' );
function my_plugin_add_menu() {
add_options_page(
'My Plugin Settings', // Page title.
'My Plugin', // Menu label.
'manage_options', // Capability required.
'my-plugin', // Menu slug (matches $hook suffix above).
'my_plugin_settings_page' // Callback that renders the page.
);
}
// 2. Register settings and fields on admin_init.
add_action( 'admin_init', 'my_plugin_register_settings' );
function my_plugin_register_settings() {
register_setting(
'my_plugin_group', // Option group.
'my_plugin_settings', // Option name.
'my_plugin_sanitise' // Sanitisation callback.
);
add_settings_section(
'my_plugin_main',
'General Settings',
null,
'my-plugin'
);
add_settings_field(
'my_plugin_count',
'Default Post Count',
'my_plugin_count_field', // Field render callback.
'my-plugin',
'my_plugin_main'
);
}
// 3. Render the count field.
function my_plugin_count_field() {
$opts = get_option( 'my_plugin_settings', array() );
$count = isset( $opts['count'] ) ? absint( $opts['count'] ) : 5;
echo '<input type="number" name="my_plugin_settings[count]" value="' . esc_attr( $count ) . '" min="1" max="20">';
}
// 4. Sanitise before saving.
function my_plugin_sanitise( $input ) {
$clean = array();
if ( isset( $input['count'] ) ) {
$clean['count'] = max( 1, min( 20, absint( $input['count'] ) ) );
}
return $clean;
}
// 5. Render the settings page.
function my_plugin_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return; // Double-check capability even though the menu already requires it.
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'My Plugin Settings', 'my-custom-plugin' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'my_plugin_group' );
do_settings_sections( 'my-plugin' );
submit_button();
?>
</form>
</div>
<?php
}
Security: Nonces, Capabilities, and Sanitisation
Plugin security has three pillars that apply every time you process user input or perform a privileged action:
1. Capability Checks
Always verify the user can perform the requested action before doing it:
if ( ! current_user_can( 'edit_posts' ) ) {
wp_die( 'You do not have permission to do this.' );
}
Use the minimum capability required: edit_posts for content changes, manage_options for site settings, delete_users for user management. For custom permission models, see building custom WordPress user roles and capabilities.
2. Nonces (CSRF Protection)
A nonce is a single-use token tied to a specific action and the current user. It prevents cross-site request forgery — an attacker tricking an admin into submitting a form they didn’t intend to:
// In the form (generate):
wp_nonce_field( 'my_plugin_save', '_wpnonce_my_plugin' );
// In the handler (verify — die if invalid):
check_admin_referer( 'my_plugin_save', '_wpnonce_my_plugin' );
// For AJAX handlers — verify manually:
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'my_ajax_action' ) ) {
wp_send_json_error( 'Invalid nonce.' );
}
3. Sanitise Input, Escape Output
Sanitise everything coming in; escape everything going out. These are separate operations with separate functions:
// Sanitise (clean input before storing):
$title = sanitize_text_field( $_POST['title'] ); // Strips tags, trims whitespace.
$count = absint( $_POST['count'] ); // Positive integer only.
$content = wp_kses_post( $_POST['content'] ); // Allows safe HTML (like TinyMCE output).
$url = esc_url_raw( $_POST['redirect_url'] ); // Safe for storage, not output.
$slug = sanitize_key( $_POST['slug'] ); // Lowercase alphanumeric + hyphens.
// Escape (safe for output — never skip these):
echo esc_html( $title ); // Plain text in HTML.
echo esc_attr( $title ); // Inside an HTML attribute.
echo esc_url( $url ); // In href/src attributes.
echo wp_kses_post( $content ); // HTML with allowed tags.
For the complete sanitisation and validation decision tree, see sanitisation vs validation in WordPress.
Activation, Deactivation, and Uninstall Hooks
Three lifecycle hooks let your plugin respond to install/remove events. A critical gotcha: register_activation_hook() must be called from the main plugin file — the one WordPress activates. It doesn’t work from an included file:
// All three must be in the main plugin file (not an included file):
register_activation_hook( __FILE__, 'my_plugin_activate' );
register_deactivation_hook( __FILE__, 'my_plugin_deactivate' );
register_uninstall_hook( __FILE__, 'my_plugin_uninstall' );
function my_plugin_activate() {
// Create custom DB tables, set default options, flush rewrite rules.
if ( ! get_option( 'my_plugin_settings' ) ) {
update_option( 'my_plugin_settings', array( 'count' => 5 ) );
}
flush_rewrite_rules(); // Required if you register custom post types or taxonomies.
}
function my_plugin_deactivate() {
// Clean up transients, deregister cron jobs.
wp_clear_scheduled_hook( 'my_plugin_cron_event' );
flush_rewrite_rules();
}
function my_plugin_uninstall() {
// Only runs when the user clicks "Delete" in the plugins screen.
// Remove all plugin data: options, custom tables, user meta.
delete_option( 'my_plugin_settings' );
}
Class-Based Structure for Larger Plugins
For anything beyond a simple utility, wrap your plugin in a class to avoid polluting the global function namespace:
<?php
// my-custom-plugin.php (main file — header + bootstrap only)
if ( ! defined( 'ABSPATH' ) ) { exit; }
require_once plugin_dir_path( __FILE__ ) . 'includes/class-my-plugin.php';
// Instantiate on plugins_loaded so all plugins/themes are available:
add_action( 'plugins_loaded', function() {
new My_Custom_Plugin();
} );
// includes/class-my-plugin.php
class My_Custom_Plugin {
public function __construct() {
add_action( 'init', array( $this, 'register_shortcodes' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
}
public function register_shortcodes() {
add_shortcode( 'my_shortcode', array( $this, 'render_shortcode' ) );
}
public function render_shortcode( $atts ) {
// ...
}
public function enqueue_assets() {
// ...
}
public function add_admin_menu() {
// ...
}
}
Using array( $this, 'method_name' ) as the callback instead of a global function string keeps all logic inside the class and makes the code testable via PHPUnit. For a complete testing approach, see unit testing WordPress plugins with PHPUnit.
Setting Up a Local Development Environment
Build and test plugins in a local environment before activating on any live site. The current standard options are LocalWP (simplest setup), Lando (Docker-based, closest to production), or plain Docker. For a comparison and setup guide, see setting up a modern local WordPress development environment.
What to Build Next
The PHP foundation covered here is the entry point. From here, the most common next steps are:
- Custom Post Types and Taxonomies — register CPTs and taxonomies from a plugin (not a theme, so they survive theme switches). See WordPress custom post types: complete guide.
- React admin UI — for plugins that need an interactive admin interface (dashboards, live-filtering lists), replace the PHP-rendered settings page with a React component. See building a WordPress plugin with React JS.
- Custom Gutenberg blocks — for content-editor integration, register blocks from your plugin. See how to build a custom Gutenberg block.
- REST API endpoints — for decoupled or headless architectures, expose plugin data via custom REST routes. See building custom WordPress REST API endpoints.


