WordPress Plugin Development: Structure, Hooks and Good Practice
How to structure a WordPress plugin properly: the header, activation, hooks and filters, settings, security and uninstall cleanup.

Most WordPress developers write their first plugin the day they realise functions.php is the wrong home for something. It usually happens right after a theme change makes half the site disappear.
A plugin is less ceremony than it looks — one PHP file with a comment header is a valid plugin. Here's how to structure one that's still maintainable a year later.
What belongs in a plugin
The test is simple: if the site switched themes tomorrow, should this still be true? If yes, it's a plugin.
- Custom post types and taxonomies. Registering these in a theme means the content becomes invisible on a redesign.
- Shortcodes, because content containing them shouldn't break when the design changes.
- REST endpoints and integrations.
- Business logic: pricing rules, workflow, anything the business depends on.
- Tracking scripts and third-party embeds.
- Things that genuinely belong in the theme: enqueuing the theme's own assets, template overrides, and presentation logic. That's about it.
The minimum viable plugin
A plugin is a PHP file in wp-content/plugins with a header comment giving at least a Plugin Name. WordPress reads that header and lists it on the Plugins screen.
For anything beyond a snippet, use a directory with a main file named after it, and split the code into an includes directory by concern: post types, admin, front end, REST, integrations.
Two lines belong at the top of every PHP file: a check that ABSPATH is defined, so the file can't be executed directly by a browser, and a namespace or consistent prefix on everything you declare so nothing collides with another plugin.
Hooks: actions and filters
WordPress's entire extension model is hooks, and the distinction between the two kinds is the thing to internalise.
An action fires at a point in execution and lets you do something — send an email when a post is published, register a post type during initialisation. Your callback returns nothing meaningful.
A filter passes a value through your callback and uses what you return. Changing the excerpt length, modifying a query, altering the content before it's displayed. You must return a value, and returning nothing is the most common filter bug there is.
Priority decides order when several callbacks attach to the same hook. Lower numbers run earlier, default is 10. If your callback runs before something it depends on, the priority is usually why.
Hook late enough. Registering a post type on plugins_loaded is too early; it belongs on init. Trying to use a conditional tag before the query has run gives you wrong answers silently.
Activation, deactivation and uninstall
Three distinct lifecycle points, and they're used wrongly often enough to be worth spelling out.
- Activation runs once when the plugin is activated. Use it to create database tables, set default options, and flush rewrite rules after registering post types. It does not run on update.
- Deactivation runs when the plugin is switched off. Clear scheduled events here. Do not delete data — deactivation is often temporary, for debugging.
- Uninstall runs when the plugin is deleted, either via an uninstall.php file or a registered hook. This is where data cleanup belongs: drop your tables, remove your options, delete your meta.
- Leaving everything behind on uninstall is common and rude. At minimum, offer a setting so the user can choose.
Security, non-negotiably
- Escape on output, every time: esc_html, esc_attr, esc_url, wp_kses_post for content that may contain markup. Escape late, at the point of output, not when you store.
- Sanitise on input: sanitize_text_field, sanitize_email, absint and friends, matched to what the value should be.
- Validate as well as sanitise. Sanitising makes a value safe; validating makes sure it's the right value.
- Use nonces on every form and AJAX action that changes something, and verify them server-side.
- Check capabilities with current_user_can before performing privileged actions. A nonce proves intent, not permission — you need both.
- Use $wpdb->prepare for any custom SQL, and prefer the WordPress APIs over custom SQL wherever they'll do.
- Never trust anything from a request, including hidden fields and values you put there yourself.
Settings and options
If your plugin needs configuration, use the Settings API rather than hand-rolling a form. It handles the form rendering, the nonce, the capability check and the saving, all consistently with the rest of the admin.
Store settings as a single option containing an array rather than a dozen separate options. Fewer rows, one sanitisation callback, and one thing to delete on uninstall.
Mind the autoload flag. An option you only read on one admin screen doesn't need loading on every front-end request — set autoload to no for anything large or rarely used.
Performance habits
- Don't run queries on every page load for something you need on one screen. Check the context first.
- Cache expensive results in transients, keyed properly, and invalidate on the event that makes them stale rather than waiting for expiry.
- Avoid remote HTTP calls during a page render. They block PHP until the remote server answers, and a slow third party becomes your slow site. Queue them.
- Enqueue scripts and styles only on the pages that need them, not on every admin screen and every front-end page.
- Watch for N+1 patterns: a loop calling get_post_meta per item, or a query inside a foreach.
Making it maintainable
The plugin will outlive your memory of writing it. A few habits make the difference.
- Follow the WordPress coding standards and run PHP_CodeSniffer with the WordPress ruleset, so the next person reads familiar code.
- Declare a minimum PHP and WordPress version in the header, and actually check them on activation rather than letting the site fatal.
- Version the plugin properly and keep a changelog, even a terse one.
- Keep it in version control and deploy it like code, rather than editing files on the server.
- Write down, somewhere findable, what the plugin is for and who asked for it.
The site-specific plugin
The most useful plugin most sites have is the unglamorous one: a small plugin named after the site, containing that site's custom post types, shortcodes, tweaks and integrations.
It costs nothing to create, it makes the theme genuinely replaceable, and it gives every future snippet an obvious home that isn't functions.php. If a site doesn't have one, creating it is usually the first thing I do.
Frequently asked questions
When should code go in a plugin instead of functions.php?
Whenever it should survive a theme change. Custom post types, shortcodes, REST endpoints, business logic and tracking scripts all belong in a plugin. The theme is for presentation: enqueuing its own assets, template overrides and display logic. If a redesign would break it, it's in the wrong place.
What's the difference between an action and a filter?
An action lets you do something at a point in execution and returns nothing meaningful. A filter passes a value through your callback and uses what you return — so a filter callback must always return a value. Forgetting that return is the single most common filter bug.
Do I need to flush rewrite rules in my plugin?
Once, on activation, after registering your post types and taxonomies — otherwise their archives 404. Never call it on every page load; it's an expensive operation that rewrites the rules option each time. Registering on init and flushing on activation is the correct pairing.
Should my plugin delete its data when uninstalled?
Yes, or at least offer the choice. Leaving options, tables and meta behind forever is common and it's how databases accumulate junk from plugins removed years ago. Put the cleanup in uninstall.php or a registered uninstall hook — not in the deactivation hook, since deactivation is often temporary.
How do I make sure my plugin is secure?
Escape on output every time, sanitise and validate everything coming in, verify a nonce on anything that changes state, and check capabilities with current_user_can before privileged actions. A nonce proves intent, not permission — you need both. Use $wpdb->prepare for any custom SQL.
Topics
- WordPress plugin development
- create a WordPress plugin
- WordPress filters
- site-specific plugin