WordPress Plugin Development: A Practical Guide for 2026

A WordPress plugin is a PHP file with a header comment WordPress reads during its plugin scan. Drop the folder in wp-content/plugins, add the header, guard the file with an ABSPATH check, and it activates. No build step needed. Just keep the header honest so WordPress blocks incompatible installs.
Part of the complete web development guide.
On this page
- What a Plugin Actually Is
- The header fields that matter
- Hooks: Actions and Filters
- Priority and arguments
- Building Blocks in 2026
- Security: Where Most Plugins Get It Wrong
- Database queries
- Testing Before You Ship
- Debugging tools worth having on
- Shipping and Maintenance
- FAQ
- Should I build a plugin or put the code in functions.php?
- Do I need a build step to write a plugin?
- How do I keep a plugin secure long term?
Most WordPress plugin tutorials show you a twenty-line file that prints “Hello World” and call it a day. That teaches you almost nothing about the parts that matter: how the plugin lifecycle works, how to avoid breaking someone else’s site, and how to ship a version you can actually maintain. Here’s the version I wish I’d had when I started building plugins for real projects.
What a Plugin Actually Is
A plugin is just a PHP file with a header comment that WordPress reads during the plugin scan. That’s it. No compilation, no build step required. Drop a folder in wp-content/plugins/, add the header, and WordPress lists it in the admin.
<?php
/**
* Plugin Name: Acme Shipping Rates
* Description: Adds flat-rate shipping tiers to WooCommerce checkout.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 8.1
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: acme-shipping
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // No direct file access.
}
That ABSPATH check at the top is not optional. Without it, someone can hit your PHP file directly and possibly trigger code you never meant to run outside WordPress.
The header fields that matter
- Requires at least and Requires PHP: WordPress 6.5+ actually surfaces these in the admin and blocks activation on incompatible sites. Use them honestly.
- Text Domain: must match your plugin folder slug if you want translations to load.
- Version: bump it on every release. Caching layers and update checks key off this string.
Hooks: Actions and Filters
Everything in WordPress runs through hooks. An action lets you run code at a specific moment. A filter lets you modify a value as it passes through. If you understand these two, you understand 80% of plugin development.
// Action: run something after a post is saved.
add_action( 'save_post', function ( $post_id, $post ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Do your work here.
}, 10, 2 );
// Filter: change a value and return it.
add_filter( 'the_content', function ( $content ) {
if ( ! is_singular( 'post' ) ) {
return $content;
}
return $content . '<p class="acme-footer">Thanks for reading.</p>';
} );
Two rules I follow and you should too: always return the value in a filter (returning nothing breaks the chain), and always check capabilities before doing anything destructive in an action.
Priority and arguments
The third argument to add_action() is priority (default 10). Lower numbers run first. The fourth is how many arguments your callback accepts. If your callback takes two parameters but you don’t pass 2 as the fourth argument, the second one arrives as null and you’ll spend an hour debugging it. I’ve done this. More than once.
Building Blocks in 2026
The block editor is the default editing experience now, and the classic metabox approach for custom content is on the way out. If you’re building something users will interact with in the editor, register a block. The modern path uses block.json and a small build step.
// block.json
{
"apiVersion": 3,
"name": "acme/shipping-notice",
"title": "Shipping Notice",
"category": "widgets",
"icon": "truck",
"editorScript": "file:./index.js",
"style": "file:./style.css",
"attributes": {
"message": { "type": "string", "default": "" }
}
}
// register on init
add_action( 'init', function () {
register_block_type( __DIR__ . '/build' );
} );
Run wp-scripts build (from @wordpress/scripts) and it compiles your JSX and copies the manifest. That toolchain handles the WordPress dependency externals for you, which saves a lot of config pain.
Security: Where Most Plugins Get It Wrong
Nearly every serious WordPress vulnerability falls into one of three buckets: missing capability checks, missing nonces, or unsanitized input. Here’s the pattern that covers all three for a form submission.
add_action( 'admin_post_acme_save_settings', function () {
// 1. Capability check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Not allowed.', 403 );
}
// 2. Nonce check.
check_admin_referer( 'acme_save_settings' );
// 3. Sanitize before saving.
$rate = isset( $_POST['rate'] )
? floatval( $_POST['rate'] )
: 0.0;
update_option( 'acme_flat_rate', $rate );
wp_safe_redirect( admin_url( 'options-general.php?page=acme' ) );
exit;
} );
And when you output anything back to the page, escape it: esc_html() for text, esc_url() for links, esc_attr() for attributes, wp_kses_post() for rich content. Sanitize on the way in, escape on the way out. Do both, every time.
Database queries
Never interpolate variables into SQL. Use $wpdb->prepare():
global $wpdb;
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, email FROM {$wpdb->prefix}acme_signups WHERE status = %s LIMIT %d",
'active',
50
)
);
Note that the table name is interpolated (it’s not user input) while the values are placeholders. That’s the correct split.
Testing Before You Ship
I test plugins against a matrix, not a single install. At minimum:
- Current WordPress plus the previous major version.
- PHP 8.1 through 8.4. PHP 8.4 deprecates a lot of implicit nullable parameter usage, so older code will throw warnings.
- A site with a persistent object cache and one without.
- Multisite, if there’s any chance someone network-activates it.
For automated testing, WP-CLI plus a test scaffold gets you integration tests against a real database. For a plugin that touches checkout or authentication, that investment pays for itself the first time you avoid a regression.
Debugging tools worth having on
// wp-config.php, development only
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'SCRIPT_DEBUG', true );
Logs land in wp-content/debug.log. Keeping display off prevents warnings from corrupting your AJAX responses, which is a fun bug to chase.
Shipping and Maintenance
Distribution options in 2026: the WordPress.org repo, a commercial marketplace, or selling direct with your own license server. The repo gives you free updates and discoverability but forbids certain commercial upsell patterns and requires GPL compatibility. Direct sales give you full control and full responsibility for updates and support.
Whatever route you pick, version your releases with semantic versioning and keep a changelog. When a user’s site breaks after an update, the first thing anyone asks is what changed. Have an answer ready.
If you’re building this into a larger site rather than a standalone product, the plugin is often one piece of a bigger architecture decision. I do custom web development for businesses around Commack and across Long Island, and the plugin-versus-theme-versus-external-service question comes up on nearly every project. The right answer depends on whether the functionality is content, presentation, or business logic. Business logic almost always belongs in a plugin so it survives a theme change.
FAQ
Should I build a plugin or put the code in functions.php?
If the code does anything a user would miss after switching themes, make it a plugin. Functions.php is tied to the theme and dies with it. Plugins are portable and survive updates to everything else.
Do I need a build step to write a plugin?
No. A plugin can be a single PHP file with no tooling at all. You only need a build step once you’re writing blocks, using modern JavaScript, or bundling dependencies. Start simple and add tooling when the code demands it.
How do I keep a plugin secure long term?
Check capabilities and nonces on every state-changing request, sanitize input, escape output, use prepared statements, and keep dependencies current. Then read your own code a month later with fresh eyes. Most vulnerabilities are things the author assumed were handled but never verified.


