Most WordPress plugin development follows the same arc. Somebody needs a booking form, a feed importer or an extra field at checkout, a developer writes it, it works, everyone moves on. Two years later the site is stuck on an old version of WordPress because nobody is confident that plugin will survive an update, and whoever wrote it has gone.
The cause is rarely that core moves too fast. WordPress is conservative about breaking things, and plenty of well written plugins from five years ago still run unchanged on WordPress 7.1. Plugins break because of decisions taken in the first week: functionality put in the theme, core files edited instead of hooked, data stored in whichever structure was nearest, and nobody ever testing against a release candidate.
What makes a custom WordPress plugin survive core updates? Four things. The code lives in a plugin rather than the theme. It extends WordPress through actions and filters instead of editing core files. It stores each piece of data in the structure that matches that data’s shape. And somebody tests it against every release candidate before that release ships.
Why WordPress Plugin Development Belongs in a Plugin, Not the Theme
The default home for custom code is the theme’s functions.php, because it is already there and it already runs. It is also the file that disappears at the next redesign.
A theme is presentation. Change themes and everything the old theme was doing stops. Custom post types stop being registered, so the content sits in the database with no admin screen and no permalink. Shortcodes render as raw text mid page. The analytics snippet, the schema markup and the nightly call to the CRM all go, and nothing raises an error.
The rule is simple enough to put in a brief. Anything that must still be true after a redesign belongs in a plugin: custom post types and taxonomies, integrations with anything external, shortcodes and blocks, business rules, scheduled jobs, and anything that writes to the database. The theme keeps templates, styles and template parts.
The price arrives late. At the next redesign you either pay again to rebuild what already existed, or copy the old functions.php forward. On a site with years of accumulated snippets that is several thousand pounds of avoidable work, and it is why a redesign quote comes back at twice what the client expected. A child theme is still a theme.
The Extension Model, and the One Rule That Matters
WordPress is built to be changed from the outside. The mechanism is hooks, and the hooks documentation describes them as the predefined spots where one piece of code can interact with or modify another. An action fires at a defined moment and lets you do something: send a notification after a post publishes, or register a post type. A filter hands you a value, expects you to change it or leave it alone, and expects you to hand it back.
The rule that follows is absolute. If you are editing a file inside wp-admin, wp-includes or another plugin’s directory, you have already lost. Those edits are erased by the next update, with no warning, no error and usually no discovery until a client reports that something stopped working. Ask a developer this directly before hiring them.
When the hook you need does not exist, wrap the behaviour rather than replacing it, move up to a broader hook, fork the third party plugin under version control with the divergence documented, or ask upstream for the hook, which is how most of them got added.
Naming, Prefixes and One Very Crowded Namespace
PHP in WordPress runs in a single global namespace shared with core, the active theme and every other active plugin. Two plugins that both declare a function called get_settings() do not compete politely: the second is a fatal error and the site is white.
Prefixes are longer than you think
The handbook’s plugin best practices page asks for a unique prefix on everything globally accessible, at least four characters and preferably five, avoiding common English words and never wp_, _ or WordPress itself. With tens of thousands of plugins in circulation, three letters from the client’s initials is a coin toss.
Namespaces and autoloading
Modern practice solves half of the problem. Declare a PHP namespace, put one class in one file, and let a PSR-4 autoloader find them, so there are no manual require statements and no chance of a class name colliding with another plugin’s. It also makes the code testable, because classes with constructor dependencies can be instantiated without WordPress being loaded.
Autoloading does not fix two plugins shipping different versions of the same library. Whichever loads first wins. Prefix vendor namespaces at build time for anything distributed.
The strings namespaces cannot help with
A namespace covers PHP symbols. Much of what a plugin registers is not a PHP symbol but a string in a shared registry, and those still need the old prefix convention: hook names, option and transient keys, post meta keys, post type and taxonomy names, shortcode tags, cron event names, REST namespaces and custom table names. They live in a flat space where the last registration wins or two plugins silently share state.
Two limits are worth knowing before naming anything. A post type key must not exceed 20 characters and a taxonomy key must not exceed 32, both lowercase alphanumeric with dashes and underscores. A five character prefix leaves 15 characters for a post type name, which is less room than it sounds.
Choosing Where the Data Lives
This is the decision with the longest tail. Get it wrong and the plugin works fine at launch, gets slower every month as the data grows, and by the time anybody notices the fix is a migration rather than an edit.
Options and transients
Options are for site wide settings: a handful of keys, small values, read on most requests. The trap is autoloading, because every autoloaded option is fetched on every single request, including admin-ajax and REST calls, whether or not anything uses it.
WordPress 6.6 changed the mechanics, as set out in the Make WordPress Core post on disabling autoload for large options. The stored value is now on, off or auto, and an option larger than 150,000 bytes is not autoloaded by default, with the threshold adjustable through the wp_max_autoloaded_option_size filter. Treat that as a ceiling, not a target. Transients are options with an expiry, and are the right home for anything fetched from elsewhere.
Post meta is not a key value store
Post meta is for attributes of one post: a subtitle, a price, a supplier reference. It is not a general purpose key value store, and the reason is visible in the table definition. The wp_postmeta table has four columns and three keys. Only post_id and the first 191 characters of meta_key are indexed. The meta_value column is a longtext with no index on it at all.
A query filtering on a meta value therefore cannot use an index. Every clause in a meta query adds another join, and on a site with 50,000 posts carrying 20 meta rows each the table holds a million rows. Three clauses mean three joins against a million rows on every page load. It is one of the commonest reasons a site that was quick in year one is unusable in year three, and it turns up constantly in WooCommerce performance work.
Custom post types and taxonomies
A custom post type is right when the thing is content. It needs its own list screen, permalinks, revisions and an editorial workflow, and it makes sense as a page somebody could visit. A custom taxonomy is right when you need a shared vocabulary that groups those things and deserves archive pages of its own.
Both bring free machinery: admin screens, capabilities, search, the block editor and the REST API. Set show_in_rest to true or the block editor will not handle the type, and register both on the init hook, never before it.
When you actually need your own table
Your own table is right when the data is not content: high volume append only records such as event logs, import queues, price history or audit trails, or anything you will filter and sort by a field that is not a post column. Past a few hundred thousand rows queried by their own fields, a table with the right indexes outperforms post meta by orders of magnitude and stays predictable as it grows.
The cost is that you own everything: table creation and versioned migrations, cleanup in uninstall.php, your own admin screens, REST endpoints and caching. That is why the honest answer for most plugins is still a custom post type.
Security Is Four Habits, and Three of Them Get Skipped
The WordPress security handbook states the principle plainly: do not trust user input, third party APIs, or data already sitting in your database. Four habits carry almost all of the risk, and in the plugins we audit they are skipped in a consistent order. Capability checks first, nonces second, output escaping third. Prepared statements come last, because a missing one gets caught in review.
Capability checks
Every handler that changes something must ask whether this user is allowed to, which means current_user_can() with the specific capability, checked inside the handler rather than only around the button that calls it.
is_admin() is not a permission check. It reports which side of the site the request is on, and returns true for any logged in subscriber hitting an admin-ajax endpoint. An admin_post_ or wp_ajax_ action with no capability check is reachable by every registered user, which on a shop means every customer who ever ordered. Our WordPress security hardening checklist covers the site level controls around this.
Nonces
A nonce protects a form or a URL against a request the user did not intend to make. Use wp_nonce_field() in the form and check_admin_referer() in the handler, or check_ajax_referer() for AJAX. Despite the name they are not single use: they are hashes valid for a window, one day by default under a two tick scheme that puts the real lifetime between twelve and twenty four hours.
The nonces documentation is explicit that they must never be relied on for authentication, authorisation or access control. A nonce establishes that the request came from your form. It says nothing about whether that person should be allowed to do the thing.
Sanitise on the way in, escape on the way out
Validate where you can, because validation is specific: a postcode either matches the pattern or it does not. Sanitise where you cannot, with sanitize_text_field(), sanitize_email(), sanitize_key(), absint() or wp_kses_post() depending on the field.
Then escape at the point of output, every time, with esc_html(), esc_attr(), esc_url() or wp_kses_post(). The escaping documentation asks for this as late as possible, so a reviewer sees escaping and output on the same line. Escaping gets skipped more than anything else because nothing looks wrong when you skip it. The page renders perfectly until somebody puts a script tag in a field.
Prepared statements
Any query you write yourself goes through $wpdb->prepare(), which takes %d for integers, %f for floats, %s for strings and %i for identifiers such as table and column names. Placeholders are left unquoted, a literal percent sign is written twice, and a LIKE wildcard is passed inside the substitution argument rather than typed into the query. Concatenating a variable into SQL is not a style disagreement, it is the vulnerability.
The REST API and the Block Editor
A plugin written this year should expose its data through the REST API and its settings through the editor, not through a hand rolled options page.
Routes are registered with register_rest_route() on the rest_api_init hook. Since WordPress 5.5 a permission_callback argument is required, and omitting it triggers a _doing_it_wrong() notice naming the route. A genuinely public endpoint uses __return_true, which is the point of the design: making a route public becomes a deliberate line of code instead of an omission. The custom endpoints documentation also covers the argument schema, where sanitisation and validation callbacks belong so bad input never reaches your handler.
Settings are registered with register_setting() and show_in_rest set to true. That puts them on the core settings endpoint, so the block editor or an external script can read and write them through an interface that already handles authentication, permissions and validation. It removes an options page, its nonce, its form handler and the bugs living in them.
Blocks are registered from a block.json file, the recommended canonical method since WordPress 5.8. The block metadata documentation sets out the benefit: assets declared there load only on pages where the block appears, instead of site wide because a plugin is active.
Performance Discipline Inside a Plugin
Four things account for most of the plugin caused slowness we find in audits, and all four are cheap to avoid and expensive to retrofit. The first is autoloaded options, because they cost something on every request forever.
The second is uncached remote requests during a page load. A wp_remote_get() to a supplier’s API with no caching means every visitor waits for that supplier. When the supplier is slow your site is slow, and when it is down your site hangs until the timeout expires. Cache the response in a transient, set an explicit timeout, and decide in advance what the page renders when the call fails.
The third is queries inside a loop. Calling get_post_meta() for each of 200 rows is 200 round trips unless the meta cache has been primed, and WP_Query primes it for you if you let it. The fix is usually to stop disabling something, which is true of most findings in a Core Web Vitals audit.
The fourth is work done inside somebody’s page request. WP-Cron is not system cron: it is triggered on page load, so a scheduled job runs inside a visitor’s request, and on a quiet site the two o’clock job does not run until somebody visits at five. Define DISABLE_WP_CRON, drive wp-cron.php from a real system scheduler, and keep jobs short and idempotent.
Surviving Core Updates: The Part Nobody Budgets For
Core rarely deletes anything outright. Functions get deprecated, keep working and emit a notice, which is why running staging with WP_DEBUG enabled is the cheapest early warning system available. A deprecation notice is a dated invitation to fix something while it is still cheap.
The process that prevents surprises costs about an hour a quarter. Follow the core development blog so you know when a beta and then a release candidate exist. Read the Field Guide, published during the release candidate phase, which lists that release’s developer facing features and breaking changes. Then put the release candidate on a staging copy and run a written smoke test of the plugin’s real functions.
Version support matters as much as the code. WordPress requires PHP 7.4 as an absolute floor and recommends 8.3 or newer, alongside MariaDB 10.11 or MySQL 8.0. Declare Requires PHP and Requires at least in the plugin header honestly, then test on the lowest version you declared rather than on whatever the developer’s laptop runs.
Version your own plugin semantically and mean it. A patch fixes something, a minor adds behaviour without breaking anything, and a major may break provided it says what it broke. Clients on automatic updates rely on that promise.
Distribution, Licensing and How Updates Reach the Site
WordPress is released under the GPL version 2 or later, and the wordpress.org licence page sets out the project’s position that plugins and themes are derivative works which inherit the licence, while acknowledging a legal grey area over what counts as derivative.
You get the source, always, and you can hire anybody else to modify it. What the GPL does not do is force you to publish it, so a plugin built for one business can stay private. It also does not stop the developer selling the same work to somebody else. If exclusivity matters, that is a contract term rather than a licence term.
If the plugin goes into the public directory it has to satisfy the plugin directory guidelines, eighteen of them. The first requires GPL compatible licensing for everything in the package, images included. Others rule out trialware, meaning functionality locked behind a payment or upgrade, ban obfuscated code, prohibit tracking users without consent, and forbid links or credits added to the public site without permission.
If it stays private, updates become your problem. Set the Update URI header, which exists to stop a private plugin being overwritten by a similarly named one from the directory, and serve updates from your own endpoint. Leaving this to the end is how a client ends up updating by FTP.
What a Custom WordPress Plugin Costs
The bands below are UK agency prices in GBP, for work delivered to the standard described here: with tests, documentation and a named person responsible after launch. A capable WordPress and PHP developer bills roughly £400 to £600 a day, so these are statements about scope rather than rate.
A small utility plugin runs £1,500 to £3,000. One job, a few hooks, perhaps a settings toggle: redirect handling, an extra field on an order, a nightly export to a supplier.
A mid sized integration runs £3,000 to £15,000. A third party API with authentication, retries and error handling, a custom post type, admin screens and background processing. This is the most commonly commissioned size and the most frequently underestimated, because the integration is a week and the failure handling is a fortnight.
A substantial product plugin runs £20,000 to £75,000 and upwards. Its own tables, block editor interfaces, licensing and update infrastructure, multisite support, and a support burden that begins the day it ships.
Paying at the low end is not automatically wrong. It is wrong when the price came from a scope that quietly excluded the deliverables below. Our guide to WordPress developer rates and what to ask covers how to read a quote, and our WordPress development page sets out how we scope this work.
What should be in the deliverable
Ask for all of these in writing before work starts, because every one is cheap to include and expensive to add afterwards. The source, in a repository you own, with its history intact, rather than a zip file emailed on the last day. Unit tests for the business rules and an integration test for anything that writes to the database or calls an external service, which is what makes it safe to change in two years by somebody who was not there.
A readme saying what the plugin does, what it hooks, what it stores and where, what external services it calls and what happens when each of those fails. Two pages is plenty, and its absence is why plugins get replaced rather than maintained. An uninstall.php that removes options, tables, cron events and meta. And a named support arrangement covering testing against each core release and fixing what that testing finds.
Getting It Built
Mecanik builds plugins at all three sizes above, and takes over plugins written by somebody else, often the more useful engagement. Our WordPress developer and software development pages explain how we scope and deliver. If you already have a plugin nobody wants to touch, an audit against the practices above takes about a day and tells you whether it is repairable or replaceable.
Frequently Asked Questions
Should custom functionality go in a plugin or in the theme? In a plugin, unless it is purely presentational. A theme gets swapped at the next redesign and everything it was doing stops: custom post types lose their admin screens, shortcodes render as raw text, and integrations silently stop running. Anything that must still be true after a redesign belongs in a plugin.
How much does a custom WordPress plugin cost in the UK? A small utility plugin typically costs £1,500 to £3,000, a mid sized integration with a third party API and admin screens costs £3,000 to £15,000, and a substantial product plugin with its own tables and update infrastructure costs £20,000 to £75,000 or more. Developers capable of this work bill roughly £400 to £600 a day.
Is it ever acceptable to edit WordPress core or another plugin’s files? No. Those edits are erased by the next update, with no error and usually no discovery until something stops working. Use actions and filters instead. If the hook you need does not exist, wrap the behaviour, fork the plugin under version control, or ask upstream for the hook.
Do I own a plugin I paid somebody to build? You own your copy and whatever the contract says. The GPL gives you the source, the right to modify it and the right to hire anyone else to maintain it, and it does not require you to publish it, so a plugin built for one business can stay private. It does not stop the developer reselling the same work, so put exclusivity in the contract if it matters.
How do you stop a plugin breaking when WordPress updates? Test it against every release candidate on a staging copy before that version ships, run staging with WP_DEBUG enabled so deprecation notices surface early, and declare the PHP and WordPress versions the plugin supports in its header. WordPress requires PHP 7.4 as a floor and recommends 8.3 or newer.
Comments