Why Build a Custom WordPress Plugin
A custom WordPress plugin is a small, purpose-built piece of code that adds exactly one thing to a site, nothing more and nothing less. Most WordPress sites eventually reach a point where a general-purpose plugin from the directory does ninety percent of what is needed, but the last ten percent, the part that actually matters to the business, is missing, locked behind a paid tier, or bolted on in a way that fights with the theme. That is usually the moment a site owner starts looking for a developer, or starts asking whether it is worth learning to write one.
There are a few situations where a custom plugin is clearly the right call. The first is when a site needs a small, specific feature: a custom post type for staff bios, a form that writes directly to a spreadsheet-friendly database table, a shortcode that displays inventory pulled from another system. Installing a fifty-thousand-line plugin to get one twenty-line feature is a common cause of slow admin screens and plugin conflicts.
The second situation is when business logic needs to survive a theme change. Code placed in a theme's functions.php file disappears the moment someone switches themes, which is rarely what anyone intends for something like custom order statuses, tracking codes, or membership rules. A plugin keeps that logic independent of design decisions.
The third is control. A plugin you write and understand does not introduce a supply-chain risk the way an abandoned third-party plugin can, it does not phone home to a third-party server, and it does not carry twenty features you never asked for and never audited. If your team already relies on a stack of plugins, it is worth comparing what you have installed against a shortlist of genuinely useful plugins for business sites before writing anything new, since sometimes the fastest path is swapping a plugin rather than building one.
None of this means every feature request needs a custom plugin. It means that once you understand how plugins are structured and how WordPress hooks work, you have a real option on the table instead of settling for whatever the nearest marketplace plugin happens to offer. The rest of this guide walks through that structure in detail, with working code you can adapt.
There is also a longer-term ownership argument that agencies feel more than solo site owners do. A client site that leans on eight overlapping plugins to cover a handful of small custom needs is a site where every update carries a small risk of one plugin breaking another, and where diagnosing a slow admin screen means auditing eight separate codebases instead of one. A single, small, well-documented custom plugin that covers the same ground is easier to reason about, easier to test after a WordPress core update, and easier to hand to a new developer without a long walkthrough of which of the eight plugins actually does what. None of that shows up on a feature list, but it shows up in how much a site costs to maintain over several years.
Custom Plugin vs functions.php
Almost every WordPress developer has, at some point, added a function to a theme's functions.php file because it was faster than setting up a plugin. It works, right up until it does not.
The case for functions.php
functions.php is fine for changes that are genuinely tied to how the current theme looks and behaves: adjusting excerpt length, registering a theme-specific image size, adding a menu location. If the code is about presentation and would make no sense on a different theme, it belongs with the theme, either in functions.php or, better, in a small companion child theme.
The case for a plugin
Anything that represents business logic, data, or a feature the site owner would expect to keep after a redesign belongs in a plugin instead. A few concrete tests:
- If the feature involves storing data (custom fields, custom tables, options), put it in a plugin so the data-handling code is not tied to a theme's lifecycle.
- If non-developers might switch themes later (and on an agency-managed site, they eventually will), anything functional needs to be theme-independent.
- If the code needs to run even when a theme is being tested or swapped during a redesign, it has to live outside the theme.
- If you want the feature to be easy to disable for troubleshooting without touching design, a plugin can be deactivated with one click, a snippet buried in functions.php cannot.
There is a middle ground many agencies use: a small "site functions" plugin that acts like a theme-independent functions.php, holding miscellaneous snippets that are not big enough to be their own plugin. That is a reasonable pattern as long as the plugin is organized and commented, since it can otherwise turn into an unmanageable dumping ground over time. If you want a second opinion on which of your current customizations belong in a plugin versus a theme, that is exactly the kind of question we answer on a quick discovery call.
Plugin File Structure and Folder Layout
WordPress does not require much structure from a plugin. Technically, a single PHP file with the right header comment, dropped into wp-content/plugins/, is a complete and valid plugin. But a single-file approach only stays readable for the smallest of features. Once a plugin has more than one responsibility, splitting it into a folder with several files pays off quickly.
The minimum viable plugin
At its simplest, a plugin is one folder containing one PHP file with the same base name, for example wp-content/plugins/my-custom-plugin/my-custom-plugin.php. WordPress scans the plugins folder, reads the header comment at the top of each main file, and lists it on the Plugins screen.
A more organized layout
For anything beyond a handful of functions, a common and sensible layout looks like this:
- my-custom-plugin.php, the main file, holding only the header comment, constants, and the code that loads everything else.
- includes/, PHP files broken up by responsibility, for example a class that registers hooks, a file for a custom post type, a file for shortcodes.
- admin/, anything that only runs in the WordPress dashboard, such as a settings page.
- assets/, CSS and JavaScript files, plus images used by the plugin's own interface.
- languages/, translation files, if the plugin will be used on a multilingual site.
- readme.txt, a plain-text file following the WordPress.org readme format, useful even for a private plugin because it documents versions and changes in one place.
None of this is enforced by WordPress. It is a convention that exists because it makes plugins easier to hand off between developers, easier to review, and easier to keep organized as they grow. Following it from day one, even for a small plugin, saves a painful reorganization later.
Naming the plugin folder
The plugin's folder name doubles as its permanent identifier inside WordPress, referenced anywhere the plugin's path is stored, including in the database if the plugin is ever part of a multisite network. Pick a folder name once, make it specific enough that it will not collide with another plugin (site-helper is fine for a single client project, helper is not), and avoid renaming it later, since a rename effectively looks like deleting the old plugin and installing a new one from WordPress's point of view, which can silently reset any settings tied to the old plugin path.
The Plugin Header Explained
The plugin header is a PHP comment block at the very top of the main plugin file. WordPress reads this comment as plain text, it does not execute it, which is why the values inside look like a docblock rather than actual PHP code.
<?php
/**
* Plugin Name: Site Helper
* Plugin URI: https://example.com/site-helper
* Description: A small custom plugin that adds a footer note and a shortcode.
* Version: 1.0.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: WP Developer
* Author URI: https://wpdeveloper.ca
* License: GPL v2 or later
* Text Domain: site-helper
*/
if ( ! defined( 'ABSPATH' ) ) {
exit; // Stop direct access to this file.
}
A few of these lines matter more than they look:
- Plugin Name is the only field that is truly required, it is what shows up on the Plugins screen.
- Requires at least and Requires PHP let WordPress itself warn a site owner before they activate a plugin on an environment that cannot support it, rather than letting it fail silently.
- Text Domain matters if you plan to translate any strings, it should match the plugin's folder name by convention.
- The ABSPATH check right after the header is not part of the header itself, but it is standard practice on every plugin's main file. It stops the file from producing errors or leaking information if someone requests it directly by URL instead of through WordPress.
Everything else in the header, such as Author, Plugin URI, and License, is informational and shown on the Plugins screen, but has no effect on how the plugin runs.
How WordPress Hooks Work
Hooks are the single most important idea in WordPress plugin development. Nearly everything a plugin does happens by attaching a function to a hook, rather than by calling that function directly.
Think of WordPress core as running through a long, mostly fixed sequence of steps on every single request: load configuration, load plugins, determine what page is being requested, run the query, render the header, render the content, render the footer, send the response. At many points along that sequence, WordPress pauses and says, in effect, "if anyone registered a function to run here, now is the time." That pause point is a hook.
What makes this powerful is that a hook can have any number of functions attached to it, from any number of plugins and the theme, all running at that one point, each unaware of the others unless they are specifically designed to interact. This is how a WordPress site can have a dozen unrelated plugins all modifying the same page without any of them needing to know the others exist.
Priority and order
When more than one function is attached to the same hook, WordPress runs them in order of a priority number, lowest first, with a default of 10. Two functions registered at the same priority run in the order they were added. This matters when a plugin needs to run before or after another plugin's logic on the same hook, for example running validation before another plugin saves data.
Where to find available hooks
WordPress core fires a very large number of hooks, and popular plugins and themes add their own on top. The WordPress developer reference documents core hooks with the exact arguments each one passes to your function, which matters because a hook like save_post passes the post ID and post object to any function attached to it. When working on a client site, checking the theme and any major plugins in use for custom hooks they expose can save a lot of guesswork.
The hook that starts everything
Plugin files themselves are loaded very early in the WordPress request cycle, before most of the interesting hooks have fired. Because of that, calling most WordPress functions directly at the top level of your plugin file, outside of any function, will fail, since the functions those calls depend on have not been defined yet. The fix is to wrap your setup code in a function and attach that function to an early hook instead of running it immediately. plugins_loaded fires once all active plugins have been loaded, init fires once WordPress itself has finished initializing, and wp_loaded fires once everything, including the theme, is ready. Registering a custom post type, for example, almost always happens on init, not at the top level of a plugin file.
Actions vs Filters
Hooks come in two kinds, and mixing them up is the most common mistake beginners make. Both use the same underlying mechanism, but they are meant for different jobs.
Actions let you run code at a specific point, without expecting anything back. You use an action to do something: send a notification, write a log entry, print some HTML, register a new set of options.
Filters let you take a piece of data WordPress is about to use, change it, and hand it back. You use a filter to transform something: shorten an excerpt, add a class to a body tag, rewrite a page title, append text to post content.
| Question | Actions | Filters |
|---|---|---|
| Purpose | Do something at a point in time (send an email, log data, print HTML) | Change a piece of data and hand it back |
| Registered with | add_action() | add_filter() |
| Fired with | do_action() | apply_filters() |
| Return value | Ignored, your function does not need to return anything | Required, your function must return the (possibly modified) value |
| Typical use | wp_footer, save_post, wp_enqueue_scripts | the_content, the_title, wp_title |
An action example
This function hooks into wp_footer, an action that runs just before the closing body tag on every front-end page, and prints a short note. Because it is an action, the function does not need to return anything, it simply runs.
<?php
/**
* Print a short note in the site footer.
* Hooked to the wp_footer action, which WordPress runs
* just before the closing body tag on the front end.
*/
function sh_print_footer_note() {
echo '<p class="sh-footer-note">' . esc_html__( 'Built with a custom plugin.', 'site-helper' ) . '</p>';
}
add_action( 'wp_footer', 'sh_print_footer_note' );
A filter example
This function hooks into the_content, a filter that runs on post content right before it is displayed. Because it is a filter, the function must accept the incoming value and return a value, or the content disappears entirely.
<?php
/**
* Append a short callout to every single post.
* Hooked to the_content filter, so we must return
* the content, not echo it.
*/
function sh_append_callout( $content ) {
if ( is_single() && in_the_loop() && is_main_query() ) {
$callout = '<div class="sh-callout">';
$callout .= esc_html__( 'Need help with your WordPress site?', 'site-helper' );
$callout .= '</div>';
$content .= $callout;
}
return $content;
}
add_filter( 'the_content', 'sh_append_callout' );
A useful rule of thumb: if you find yourself trying to echo something inside a function attached to a filter, or trying to return a value from a function attached to an action, stop and check which type of hook you actually attached to. This single mix-up accounts for a large share of "my code isn't working" questions in WordPress development.
Building Your First Plugin, Step by Step
With the structure and hook concepts covered, here is how the pieces come together into one working plugin. This example, called Site Helper, adds a small footer note, a callout appended to single posts, and a shortcode, which is enough to demonstrate every core idea without becoming unwieldy.
Step 1: Create the folder and main file
Inside wp-content/plugins/, create a folder named site-helper, and inside it a file named site-helper.php. Add the plugin header covered earlier, followed by the ABSPATH guard.
Step 2: Add your first action
Paste in the footer note function and its add_action call shown in the previous section. Save the file, then go to the Plugins screen in wp-admin and activate Site Helper. Visit the front end of the site and confirm the note appears near the closing body tag.
Step 3: Add the filter
Add the the_content filter function from the previous section underneath the action code. Visit a single post and confirm the callout appears at the end of the content, but does not appear on archive pages or the homepage, which is exactly what the is_single() and is_main_query() checks are for.
Step 4: Enqueue any assets properly
If the plugin needs its own CSS or JavaScript, never hardcode a script tag into a template or print it directly with echo. Register and enqueue it through WordPress's asset system so it respects plugin conflicts, cache busting, and dependency order.
<?php
/**
* Register and enqueue the plugin's front-end assets.
* Always give scripts and styles a unique, prefixed handle.
*/
function sh_enqueue_assets() {
wp_enqueue_style(
'sh-plugin-style',
plugin_dir_url( __FILE__ ) . 'assets/plugin.css',
array(),
'1.0.0'
);
wp_enqueue_script(
'sh-plugin-script',
plugin_dir_url( __FILE__ ) . 'assets/plugin.js',
array( 'jquery' ),
'1.0.0',
true
);
}
add_action( 'wp_enqueue_scripts', 'sh_enqueue_assets' );
Notice the version number passed as the fourth argument to wp_enqueue_style and the fourth argument to wp_enqueue_script. Bumping that number whenever you change the file is what forces browsers to fetch the new version instead of serving a cached copy.
Step 5: Test it like a real feature
Deactivate and reactivate the plugin, check the browser console for JavaScript errors, and check the page source to confirm the footer note and callout are actually in the markup, not just visually present through some other plugin's styling. A five-minute check here catches most beginner mistakes before a client ever sees them.
That is a genuinely complete, working plugin. Everything from here is about making it safer, more maintainable, and ready to hand off to someone else, which matters more than it sounds like it should the first time a plugin needs to be updated eighteen months later by a different developer. If a project like this is already sounding like more time than your team has, our team builds exactly this kind of custom functionality for clients every week, and a free quote costs nothing to ask for.
Put it under version control
Even a small custom plugin benefits from being tracked in git from the first commit, kept in a private repository separate from the site's regular content backups. It gives you a real history of what changed and when, an easy way to compare the current code against an earlier version when something breaks after a client edit, and a natural place to note, in a commit message, why a particular change was made. Treating a fifty-line plugin with the same discipline as a larger application is not overkill, it is what makes the plugin something you can confidently touch again a year later instead of something you are afraid to open.
Adding a Shortcode and a Settings Page
Shortcodes
A shortcode lets a site owner drop a piece of dynamic content into the block editor or a widget by typing a bracketed tag, without touching PHP. It is one of the most client-friendly features a custom plugin can offer, because it puts a working feature directly into the hands of whoever edits content, without any code.
<?php
/**
* [sh_contact_note] shortcode.
* Accepts an optional "text" attribute and always
* escapes output before it reaches the page.
*/
function sh_contact_note_shortcode( $atts ) {
$atts = shortcode_atts(
array(
'text' => 'Questions about this page? Get in touch.',
),
$atts,
'sh_contact_note'
);
$text = sanitize_text_field( $atts['text'] );
return '<div class="sh-contact-note">' . esc_html( $text ) . '</div>';
}
add_shortcode( 'sh_contact_note', 'sh_contact_note_shortcode' );
Adding [sh_contact_note] to any post or page now prints the default message, and adding [sh_contact_note text="Ask us anything"] overrides it. Two details matter here beyond just registering the shortcode. First, shortcode_atts() merges whatever attributes were actually provided with sensible defaults, so a shortcode never breaks just because an attribute was left out. Second, every value that came from the shortcode attributes is sanitized on the way in and escaped again on the way out, because shortcode attributes are still user-editable content and should be treated with the same suspicion as a form submission.
A basic settings page
Many small plugins eventually need at least one option a site owner can control without editing code, for example whether the footer note is shown at all. A full settings page involves the Settings API, register_setting(), and a callback added to admin_menu, which is more than fits comfortably in this guide, but the shape of it is straightforward: register a menu entry, render a form, and handle the submission with a nonce and a capability check, shown next in the security section. If a plugin needs more than two or three options, it is often worth having a developer build a proper settings screen rather than relying on constants edited directly in the code, since that keeps configuration accessible to whoever manages the site after launch, not just whoever wrote the original plugin. This kind of small admin interface work is a common part of the custom development work we handle for agency and business clients.
Activation, Deactivation, Uninstall, and Security
Activation and deactivation hooks
Two special hooks fire only around the plugin's own lifecycle, not on every page load. register_activation_hook() runs once, the moment a plugin is activated, and is the right place to set default options or create a custom database table if the plugin needs one. register_deactivation_hook() runs when a plugin is turned off, and is the right place to clear scheduled events, but should generally not delete the plugin's saved data, since deactivating is often temporary.
<?php
/**
* Runs once, when the plugin is activated.
* Good place to create default options or a database table.
*/
function sh_on_activate() {
if ( false === get_option( 'sh_settings' ) ) {
add_option( 'sh_settings', array( 'footer_note' => 'on' ) );
}
}
register_activation_hook( __FILE__, 'sh_on_activate' );
/**
* Runs when the plugin is deactivated.
* Clean up transients or scheduled events here, but leave the
* plugin's saved data alone in case the site owner reactivates it.
*/
function sh_on_deactivate() {
wp_clear_scheduled_hook( 'sh_daily_cleanup' );
}
register_deactivation_hook( __FILE__, 'sh_on_deactivate' );
Uninstall
Permanent cleanup, the kind that should only happen when a plugin is actually deleted rather than just switched off, belongs in a separate file named uninstall.php placed at the plugin's root folder. WordPress calls this file automatically on deletion, and only on deletion.
<?php
// uninstall.php
// WordPress calls this file only when the plugin is deleted from
// the Plugins screen, never on a simple deactivation.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'sh_settings' );
Sanitize input, escape output
This is the single rule that prevents the largest category of plugin security issues. Every piece of data coming from a user, whether from a form field, a URL parameter, or a shortcode attribute, should be run through an appropriate sanitize_ function before it is stored or used. Every piece of data being printed into HTML should be run through an appropriate esc_ function at the point it is output, not just once somewhere upstream. The two steps are not redundant, sanitizing protects what gets stored, escaping protects what gets displayed, and skipping either one opens the door to stored or reflected cross-site scripting.
Capability checks and nonces
Any code that changes data, especially in the admin area, needs two separate checks before it does anything. current_user_can() confirms the logged-in user is actually allowed to perform the action, which stops a lower-privileged user from triggering admin-only logic. A nonce, checked with wp_verify_nonce(), confirms the request actually came from the form WordPress generated, which stops another site from tricking a logged-in admin's browser into submitting a hidden request on their behalf.
<?php
/**
* A minimal, safe way to handle a settings form submission.
* Checks capability, checks a nonce, then sanitizes every field.
*/
function sh_handle_settings_save() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
if ( ! isset( $_POST['sh_settings_nonce'] )
|| ! wp_verify_nonce( $_POST['sh_settings_nonce'], 'sh_save_settings' ) ) {
return;
}
$footer_note = isset( $_POST['sh_footer_note'] )
? sanitize_text_field( wp_unslash( $_POST['sh_footer_note'] ) )
: '';
update_option( 'sh_footer_note', $footer_note );
}
add_action( 'admin_post_sh_save_settings', 'sh_handle_settings_save' );
Skipping either check is a common way a seemingly harmless custom plugin turns into the entry point for a site compromise. If you already suspect a plugin, custom or otherwise, is behind a security problem, our guide on securing a WordPress website covers the broader checklist beyond just plugin code, and it is worth a read even for sites that feel fine right now.
Direct database queries and the REST API
Most custom plugins never need a raw SQL query, the built-in functions for posts, options, and users cover the majority of cases and already handle escaping correctly. If a plugin genuinely needs a custom database table, WordPress's $wpdb class supports prepared statements through $wpdb->prepare(), and every query built with any user-supplied value should go through it rather than being assembled with plain string concatenation. The same caution applies if a plugin exposes a custom REST API endpoint: validate and sanitize every parameter using the args definition register_rest_route() accepts, and set a permission_callback explicitly rather than leaving an endpoint open by default.
Best Practices, Testing, and When to Hire Help
Prefix everything
PHP function names, in the global namespace that most WordPress code lives in, must be unique across the entire site. Two plugins both defining a function named get_settings() will produce a fatal, site-breaking error the moment both are active. The fix is a short, plugin-specific prefix on every function, class, option name, and shortcode tag, for example sh_ in the examples throughout this guide. Namespaces, available in modern PHP, are an even cleaner solution and worth using if the plugin's complexity justifies it.
Follow WordPress coding standards
WordPress has an established, documented coding standard covering indentation, spacing, and naming, and tools like PHP_CodeSniffer with the WordPress ruleset can check a plugin against it automatically. Following the standard is not about aesthetics, it is what lets any WordPress developer open your plugin's code years later and immediately understand the layout, without a separate onboarding conversation about your personal style.
Keep functions small and single-purpose
A function attached to a hook should do one job. If a function hooked to save_post is two hundred lines long and handling five unrelated tasks, split it into five smaller functions, each with its own add_action call. It makes the code easier to test, easier to disable selectively for debugging, and far easier for someone else to modify later without breaking an unrelated feature buried in the same function.
Test in a safe environment first
Never activate a new custom plugin directly on a live site for the first time. A local environment or a staging copy is the right place to activate it, click through the feature it adds, check the site's error log, and confirm nothing else on the site changed behavior unexpectedly. WordPress's debug mode, enabled by setting WP_DEBUG to true in wp-config.php on a non-production environment, will surface PHP notices and warnings a plugin might otherwise hide.
Document what the plugin does
A short readme.txt or even a comment block at the top of the main file, describing what the plugin does and why it exists, saves real time later. "Why does this site have a plugin called site-helper" is a question every agency eventually gets asked about a client site they did not originally build, and a one-paragraph answer already sitting in the code is far better than guessing from the function names.
When it is worth hiring a developer
Writing a small plugin like the one in this guide is genuinely approachable for anyone comfortable with basic PHP. Where it gets harder, quickly, is anything touching payments, user data, custom database tables, or integration with an external API, since mistakes in those areas are harder to notice and more expensive to fix after the fact. If a plugin idea has grown past what feels comfortable to build and maintain in-house, or if an existing custom plugin on a client site needs an audit before a redesign, that is exactly the kind of work we take on. You can see the shape of the work we typically do on our services page, and if a new site is part of the plan, our guide on building a WordPress website is a useful starting point. Either way, a quick message or a free quote request is the fastest way to find out what a custom build would actually involve for your specific case.
Common hooks worth knowing
Beyond the two examples used throughout this guide, a handful of hooks come up in nearly every custom plugin project:
| Hook | Type | When it fires |
|---|---|---|
| init | Action | Early on every request, once WordPress has loaded but before headers are sent |
| wp_enqueue_scripts | Action | When it is time to register or enqueue CSS and JavaScript on the front end |
| the_content | Filter | Right before post content is displayed, lets you modify the HTML |
| save_post | Action | After a post or custom post type is saved |
| wp_footer | Action | Just before the closing body tag, common place to print scripts or markup |
| admin_menu | Action | When the admin sidebar menu is being built, used to add settings pages |
Bookmark the WordPress developer hook reference. No single guide, including this one, replaces having the full, current list of core hooks on hand while you build.
Shortcodes versus blocks
Everything in this guide uses shortcodes because they are the simplest way to demonstrate a plugin adding front-end content, and they still work reliably on every WordPress site regardless of which theme or editor setup is in use. If the feature is meant to feel native inside the block editor, with its own settings panel and live preview, registering a custom Gutenberg block is the more modern equivalent, though it requires JavaScript and a build step that a plain shortcode does not. For an internal tool or a quick client request, a shortcode is usually still the faster, more maintainable choice, and it is perfectly reasonable to start there and revisit a block-based version later if the feature grows.
Keep a changelog
A short, dated list of what changed in each version, kept either in readme.txt or in a separate CHANGELOG file, turns a vague memory of "I think I fixed that a while back" into a concrete answer. It costs a minute per release and pays for itself the first time a client reports a bug that turns out to already be fixed in a version that was built but never deployed.