Almost every WordPress developer started the exact same way. You needed to change something small on a site, so you opened your active theme’s functions.php file, pasted thirty lines of PHP code you found on a forum, and crossed your fingers.
It worked. Then, three months later, the client switched themes, and every single custom feature vanished overnight. Or worse, you updated the theme, the update wiped out your edits, and the site crashed on a Friday afternoon.
That is why plugins exist.
A WordPress plugin is not some mystical, highly complex software package. At its simplest, a plugin is just a single PHP file sitting in a folder inside wp-content/plugins/. If you can write basic PHP and understand how WordPress hooks work, you can write a plugin.
Let’s walk through how to build your first plugin from scratch, the right way, without the generic fluff.
The golden rule: theme vs. plugin
Before you write a single line of code, you need to understand where your code belongs.
- Themes control presentation. How posts look, your typography, your color palettes, your layout templates.
- Plugins control functionality. Custom post types, analytics trackers, custom contact forms, API integrations, and administrative tools.
If you change your theme, you should change how your site looks, not how your site works. If your feature needs to survive a redesign, it belongs in a plugin. Full stop.
Setting up your plugin folder and main file
Navigate to your local WordPress installation. Open the directory at wp-content/plugins/.
Create a new folder named site-announcement. Inside that folder, create a file named site-announcement.php.
Always name your main file after the folder. While WordPress can technically scan any PHP file in the directory, keeping the folder name and file name identical prevents confusion when your project grows.
Open site-announcement.php in your code editor. The first thing WordPress looks for is a specific header comment block. Without this block, WordPress will completely ignore your file.
Here is the minimum boilerplate you need:
<?php
/**
* Plugin Name: Site Announcement Banner
* Plugin URI: https://example.com/site-announcement
* Description: Displays a customizable alert banner in the WordPress admin dashboard.
* Version: 1.0.0
* Author: Alex Developer
* Author URI: https://example.com
* License: GPL-2.0-or-later
*/
// Prevent direct file access.
if (!defined('ABSPATH')) {
exit;
}
Notice those last four lines. That is your first line of defense. If someone navigates directly to https://yoursite.com/wp-content/plugins/site-announcement/site-announcement.php in their browser, WordPress core will not be loaded, and ABSPATH will be undefined. The script immediately halts execution instead of exposing server paths or running isolated code. Put this guard clause at the top of every PHP file you create.
Once you save this file, log in to your WordPress dashboard and go to Plugins -> Installed Plugins. You will see “Site Announcement Banner” listed there. You can activate it right now. It does not do anything yet, but WordPress recognizes it as a valid plugin.
The WordPress engine: actions vs. filters
WordPress is built on an event-driven architecture powered by Hooks. Hooks allow your plugin to “hook into” WordPress core processes at specific points during page execution without touching core files.

Hooks come in two flavors: Actions and Filters. Beginners mix these up constantly, but the distinction is straightforward:
- Actions (
add_action) do things. They run custom functions when a specific event occurs (e.g., when a post publishes, when scripts load, or when the admin dashboard renders). Actions do not return values to WordPress. - Filters (
add_filter) modify things. They take existing data (e.g., post content, page titles, excerpt lengths), let you change it, and expect you to return the modified data back.
Example 1: Using an action hook to display an admin notice
Let’s display an administrative notice in the WordPress dashboard. We will hook into the admin_notices action.
Add this code to site-announcement.php:
function sab_display_admin_notice() {
$screen = get_current_screen();
// Only show this notice on the main Dashboard screen
if ($screen && $screen->id === 'dashboard') {
?>
<div class="notice notice-warning is-dismissible">
<p><strong>Notice:</strong> Server maintenance is scheduled for tonight at 11:00 PM UTC.</p>
</div>
<?php
}
}
add_action('admin_notices', 'sab_display_admin_notice');
When WordPress renders the admin area, it reaches the admin_notices hook and executes sab_display_admin_notice(). Our function checks if the user is on the main dashboard, and if so, prints the HTML banner.
Example 2: Using a filter hook to modify post content
Now let’s see how a filter works. Suppose we want to estimate reading time and append an estimate to the beginning of every single blog post.
function sab_add_reading_time_to_content($content) {
// Only modify content on single posts in the main query loop
if (is_singular('post') && is_main_query()) {
$word_count = str_word_count(strip_tags($content));
$minutes = ceil($word_count / 200); // Assuming 200 words per minute reading speed
$reading_banner = '<div class="sab-reading-time" style="background: #f0f4f8; padding: 10px; margin-bottom: 15px; border-left: 4px solid #0073aa;">';
$reading_banner .= '<p style="margin: 0;"><em>Estimated reading time: ' . esc_html($minutes) . ' minute(s)</em></p>';
$reading_banner .= '</div>';
// Return the modified content string
return $reading_banner . $content;
}
// CRITICAL: Always return the unmodified content if conditions are not met
return $content;
}
add_filter('the_content', 'sab_add_reading_time_to_content');
Pay close attention to the final return $content;. With filters, if you forget to return the data, you will wipe out the entire post content across the site. Filters are pass-through modifiers: data comes in, you alter it, and data must go back out.
Building a custom shortcode
Shortcodes let site editors embed dynamic features into posts or pages using tags like [callout type="warning"]Text[/callout].
Let’s register a custom shortcode handler:
function sab_callout_shortcode($atts, $content = null) {
// Parse attributes with default values
$args = shortcode_atts(
array(
'type' => 'info',
'title' => 'Note',
),
$atts,
'callout'
);
// Validate type against an allowlist
$allowed_types = array('info', 'warning', 'success');
$type = in_array($args['type'], $allowed_types, true) ? $args['type'] : 'info';
// Build output string (never use echo inside a shortcode function)
$output = '<div class="sab-callout sab-callout-' . esc_attr($type) . '" style="padding: 12px; margin: 16px 0; border: 1px solid #ccd0d4; border-radius: 4px;">';
$output .= '<strong>' . esc_html($args['title']) . ':</strong> ';
$output .= esc_html($content);
$output .= '</div>';
return $output;
}
add_shortcode('callout', 'sab_callout_shortcode');
A common beginner mistake is using echo inside a shortcode callback. Shortcodes must always return their HTML string. If you use echo, your output will render at the very top of the page before the rest of the layout, breaking the post flow.
Security essentials: nonces, sanitization, and escaping
WordPress powers over 40% of the web. That makes it a constant target for automated attacks. If you write sloppy code, your plugin will become an open door for cross-site scripting (XSS) or cross-site request forgery (CSRF).
Security in WordPress comes down to three basic habits:
- Validate and sanitize input (on the way in).
- Verify permissions and nonces (before saving or processing).
- Escape output (on the way out).
1. Function prefixing and namespacing
PHP functions in plugins run in the global scope by default. If your plugin defines a function named display_notice() and another plugin on the site also defines display_notice(), PHP throws a fatal error and takes down the entire site.
Always prefix your function names with a unique string (like sab_ for Site Announcement Banner) or use PHP namespaces:
namespace SiteAnnouncement;
function display_notice() {
// Safe from global collisions
}
2. Nonces and capability checks
Nonces (Number used Once) verify that an incoming form submission came from your actual admin page and from an authorized user, not from a malicious third-party script trying to trick an administrator.
Here is a secure form processing handler:
function sab_save_custom_setting() {
// 1. Check user permissions
if (!current_user_can('manage_options')) {
wp_die(esc_html__('Unauthorized user.', 'site-announcement'));
}
// 2. Verify nonce token
check_admin_referer('sab_save_settings_action', 'sab_settings_nonce');
// 3. Sanitize the input
if (isset($_POST['sab_notice_text'])) {
$clean_text = sanitize_text_field(wp_unslash($_POST['sab_notice_text']));
update_option('sab_notice_text', $clean_text);
}
// 4. Safe redirect back to settings page
wp_safe_redirect(admin_url('options-general.php?page=site-announcement&updated=1'));
exit;
}
add_action('admin_post_sab_save_settings', 'sab_save_custom_setting');
3. Sanitizing vs. Escaping
- Sanitizing cleans data before you save it to the database:
sanitize_text_field($str): Strips tags and extra whitespace.sanitize_email($email): Cleans email addresses.absint($num): Ensures a number is a positive integer.
- Escaping protects data right when you output it into HTML:
esc_html($str): Encodes HTML entities for plain text inside tags.esc_attr($str): Encodes quotes and characters for HTML attribute values (value="<?php echo esc_attr($val); ?>").esc_url($url): Validates and cleans URLs before outputting them inhreforsrcattributes.
Never assume that because data was sanitized going into the database, it is safe coming out. Always escape late, at the exact moment of output.
Five beginner traps to avoid
When mentoring new plugin developers, I see the exact same stumbling blocks over and over again. Save yourself hours of debugging by watching out for these:
Trap 1: Developing with debugging turned off
By default, WordPress hides PHP warnings and notices. When your plugin has a fatal error, you get a blank white screen with no explanation.
Open your local wp-config.php file and enable debugging mode:
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', false);
@ini_set('display_errors', 0);
This logs all PHP errors, notices, and warnings to wp-content/debug.log without breaking your browser UI. When something behaves unexpectedly, checking debug.log is always your first step.
Trap 2: Direct database queries with raw strings
Never write raw SQL queries like $wpdb->query("SELECT * FROM table WHERE id = " . $_GET['id']);. This is an immediate SQL injection vulnerability.
Always use $wpdb->prepare() for variable inputs:
global $wpdb;
$safe_id = absint($_GET['id']);
$results = $wpdb->get_results(
$wpdb->prepare("SELECT * FROM {$wpdb->prefix}custom_table WHERE id = %d", $safe_id)
);
Trap 3: Loading scripts on every admin page
If your plugin has custom JavaScript or CSS for its settings screen, do not enqueue it globally across the entire WordPress admin. Doing so can break other plugins or the block editor.
Check the current screen hook in your admin_enqueue_scripts callback:
function sab_enqueue_admin_assets($hook) {
// Only load scripts on our specific plugin settings page
if ($hook !== 'settings_page_site-announcement') {
return;
}
wp_enqueue_style(
'sab-admin-style',
plugins_url('css/admin.css', __FILE__),
array(),
'1.0.0'
);
}
add_action('admin_enqueue_scripts', 'sab_enqueue_admin_assets');
Trap 4: Hardcoding URLs and file paths
Never hardcode paths like /wp-content/plugins/my-plugin/. Users can rename directories or move their wp-content folder to a custom path.
Use WordPress helper functions:
plugin_dir_path(__FILE__): Returns the absolute filesystem directory path (forincludeorrequire).plugins_url('assets/script.js', __FILE__): Returns the web URL for enqueuing assets.
Trap 5: Over-engineering on day one
You do not need an elaborate 15-class object-oriented architecture, Composer dependencies, and Webpack builds to create a plugin that adds a custom tracking pixel or modifies checkout fields.
Start simple. Write clean, procedural code with clear function names, verify your hooks work, add your security checks, and test it thoroughly. When your plugin genuinely grows to thousands of lines, then refactor into classes and separate modules.
Where to go from here
You now understand the actual mechanics of a WordPress plugin: headers, lifecycle hooks, input sanitization, nonces, and output escaping.
From here, pick a tiny problem on your own site and build a plugin to solve it. Register a custom post type using register_post_type(), create a settings page using the WordPress Settings API, or create a custom REST API endpoint with register_rest_route().
The best way to learn is by building small, focused utilities that do one job reliably.
Changes
| Pass | What changed | Examples |
|---|---|---|
| Structure | Replaced vague checklist with concrete hands-on tutorial | Step 1-5 lists -> working PHP files |
| Inflation | Stripped promotional puffery and generic superlatives | “invaluable tools that transform” -> deleted |
| Vocabulary | Replaced AI buzzwords and metaphors | “journey”, “landscape”, “delve” -> dropped |
| Grammar | Replaced copula avoidance and passive phrasing | “serves as” -> “is”, active voice throughout |
| Rhythm/Style | Added punchy sentences and realistic developer tone | “That is why plugins exist.” “Full stop.” |
| Hedging/Filler | Cut introductory fluff and meta-announcements | “In this comprehensive guide…” -> deleted |
| Transitions | Removed repetitive connectors | “Moreover”, “Additionally” -> natural flow |
| Soul | Added mentoring voice, real-world mistakes, and traps | wp-config.php debugging, theme updates wiping code |