WordPress Plugin Development
Overview
This skill covers building WordPress plugins that are correct, secure, maintainable, and directory-ready. It follows the structure of the official Plugin Handbook, with each chapter's practical content in references/.
Two rules govern everything else:
1. Every entry point needs three gates. Nearly every plugin CVE is a missing gate, not an exotic exploit.
Gate 1 AUTHENTICATION Is this a logged-in user? is_user_logged_in()
Gate 2 AUTHORIZATION May THIS user do THIS thing? current_user_can( 'cap' )
Gate 3 INTENT Did they mean to, right now? check_admin_referer()
Then INPUT Sanitize + validate every request value
Then OUTPUT Escape at the point of printing, per context
A nonce without a capability check is not security — it only proves the request came from your form, not that the user is allowed. A capability check without a nonce leaves you open to CSRF. You need both.
2. Prefix everything and never touch what isn't yours. Every function, class, constant, option, meta key, table, and handle in the public namespace carries a unique plugin prefix. A plugin shares one global PHP namespace with core and every other active plugin.
Workflow
Building a new plugin
- Clarify what cannot be inferred — see "Ask before you build" below. Capability model, public vs. authenticated endpoints, and data storage shape come first; getting them wrong means a rewrite or a vulnerability.
- Scaffold —
references/plugin-basics.mdfor the header, file layout, and activation/deactivation/uninstall lifecycle. Pick an architecture (single file, class-based, or a full structure) proportional to the plugin's scope. - Hook in —
references/hooks.md. Plugins do everything through actions and filters; get priority and argument count right. - Build the feature — the relevant reference below.
- Gate every entry point —
references/security.mdandreferences/entry-points.md. - Translate — wrap every user-facing string from the start; retrofitting i18n is painful (
references/i18n.md). - Verify —
references/testing-tools.md, then runscripts/wp-plugin-audit.sh.
Adding a feature to an existing plugin
Match the existing architecture and prefix conventions before introducing new ones. Read the relevant reference, then apply the three gates to anything that handles a request.
Reviewing plugin code
- Run
scripts/wp-plugin-audit.sh <plugin-dir>for grep-based triage. - Walk
references/audit-checklist.md, ordered by real-world exploitation frequency. - For every finding, establish reachability — unauthenticated is critical, admin-only is usually low.
- Report with file:line, class, who can exploit it, impact, and the fix.
Preparing for WordPress.org
references/plugin-directory.md, then run Plugin Check — all Security-category checks must pass.
Ask before you build
Some decisions cannot be inferred from a request, and guessing them produces a plugin that is wrong in a way the developer only discovers in production — or a vulnerability. Ask the developer, then build.
The rule of thumb: if getting it wrong means a security hole, data loss, or rewriting the feature, ask. Otherwise pick the sensible default and say what you picked.
Always ask
Who is allowed to do this? Every feature that writes data, changes settings, or exposes information needs a named capability. "Add a page where users can edit records" does not say whether that means administrators, editors, or any logged-in subscriber — and the difference between manage_options and edit_posts is the difference between a locked door and an open one.
Which users should be able to do this? Administrators only (
manage_options), editors and above (edit_others_posts), any author for their own items (edit_post+ ownership check), or any logged-in user?
Is this endpoint public? Before registering wp_ajax_nopriv_* or 'permission_callback' => '__return_true':
Should logged-out visitors be able to call this? That makes it a public, unauthenticated endpoint on the open internet.
Where does this data live? Post type, taxonomy, post meta, options, or a custom table are not interchangeable — changing later means migrating real data.
Is this a content item with its own page (custom post type), a label that groups items (taxonomy), a per-item value (meta), or high-volume relational data (custom table)?
Who owns a record, and can users see each other's? A capability check alone does not stop one author editing another's item.
Should users see and edit only their own records, or everyone's?
Destructive behaviour on uninstall. Deleting user data is irreversible.
On uninstall, should the plugin delete its data, or leave it in place in case the plugin is reinstalled?
Data that identifies a person. Triggers privacy obligations — see references/privacy.md.
Does this store personal data (emails, IPs, names)? If so it needs export and erasure handlers.
Ask when it materially changes the work
- Scale — "how many records do you expect?" decides meta vs. custom table, and whether pagination is needed.
- Multisite — network-activated plugins need per-site handling in activation and uninstall.
- Minimum WordPress/PHP version — decides whether modern syntax and newer APIs are available.
- Editor target — block editor, classic editor, or both.
- External services — an API integration needs to know the auth model and what happens when the service is down.
- Existing conventions — when adding to an existing plugin, match its architecture and prefix rather than introducing new ones.
Don't ask
Don't stall on things with an obvious right answer or a safe default. Choose, state the choice in one line, and keep going:
- Whether to sanitize, escape, use a nonce, or prefix — these are never optional.
- Text domain, file layout, naming conventions — follow the existing plugin or the standard.
- Whether to use
$wpdb->prepare()— yes. - Anything already answered in the request, the code, or this skill.
How to ask
Ask before writing the affected code, not after. Group related questions into one exchange rather than interrupting repeatedly. Offer concrete options with a recommendation, since developers often have not thought about the capability model:
Before I build the delete handler — who should be able to delete records?
- Administrators only (
manage_options) — safest default- Editors and above (
delete_others_posts)- Any author, own records only (
delete_post+ ownership check)I'd suggest 3 if end users create these records, 1 if they're site configuration.
If the developer does not answer, or says "you decide", choose the most restrictive option that still meets the request, and say so explicitly:
Defaulting to
manage_options(administrators only). Loosen it by changing the capability inmyplugin_delete_handler().
Restrictive defaults fail closed — a too-tight permission is a support request, a too-loose one is a CVE.
Reference map
Pick the file matching the task. Each maps to a Plugin Handbook chapter.
| Task | Reference |
|---|---|
| Plugin header, file structure, activation/uninstall, architecture | references/plugin-basics.md |
| Actions, filters, custom hooks, priority, removing hooks | references/hooks.md |
| Capability + nonce + sanitize + escape; the 8 vulnerability classes | references/security.md |
| Every HTTP path into plugin code and the gates each needs | references/entry-points.md |
| Admin menus, submenus, admin pages | references/admin-pages.md |
| Settings API, Options API, settings pages | references/settings.md |
| Post meta, meta boxes, register_meta, term/user meta | references/metadata.md |
| Custom post types and taxonomies | references/post-types-taxonomies.md |
| Roles, capabilities, user meta, user queries | references/users-roles.md |
| Enqueuing scripts/styles, AJAX, passing data to JS | references/javascript-ajax.md |
| REST API routes, permission callbacks, schema | references/rest-api.md |
| Database access, $wpdb, custom tables, dbDelta | references/database.md |
| Outbound HTTP requests, transient caching | references/http-api.md |
| Scheduled tasks, WP-Cron | references/cron.md |
| Shortcodes | references/shortcodes.md |
| Blocks and the block editor | references/blocks.md |
| Translation functions, text domains, translator comments | references/i18n.md |
| Personal data export/erasure, privacy policy | references/privacy.md |
| WP_DEBUG, WP-CLI, PHPUnit, Query Monitor, PHPCS | references/testing-tools.md |
| Performance: caching, transients, query efficiency | references/performance.md |
| Directory submission, Plugin Check, guidelines | references/plugin-directory.md |
| Ordered security review checklist with grep patterns | references/audit-checklist.md |
| Supply-chain and planted-backdoor detection | references/backdoor-indicators.md |
Non-negotiable rules
These apply to every line of plugin code. Details and code in references/security.md.
Never trust request data. $_GET, $_POST, $_REQUEST, $_COOKIE, $_FILES, $_SERVER (including HTTP_REFERER, HTTP_X_FORWARDED_FOR, REQUEST_URI). A nonce does not make input trustworthy — it only proves origin.
Sanitize on input, escape on output, validate before use. Three separate operations; doing one does not excuse skipping the others. Escape late, at the point of printing, choosing the function by context.
wp_ajax_nopriv_ and __return_true mean unauthenticated. Register the nopriv AJAX variant or a public permission_callback only for genuinely public actions.
permission_callback is mandatory on every REST route, and the check belongs in it — not in the route callback.
Check capabilities, never roles. current_user_can( 'manage_options' ), never current_user_can( 'administrator' ) or a $user->roles comparison.
Use $wpdb->prepare() for every query containing a variable — and never interpolate into prepare()'s first argument. esc_sql() is not a substitute.
Never let user input reach eval(), assert(), create_function(), system(), exec(), shell_exec(), passthru(), include/require with a variable path, unserialize(), extract(), or call_user_func() with a user-supplied name.
Prefix everything public — functions, classes, constants, options, meta keys, tables, script handles, globals. Four characters minimum; wp_ is reserved.
Block direct file access. Every PHP file opens with defined( 'ABSPATH' ) || exit;.
Wrap every user-facing string in a translation function with a literal text domain matching the plugin slug.
Never modify core. No editing WordPress files, no writing to wp-config.php, no altering other plugins' data.
Clean up after yourself. Deactivation clears scheduled events; uninstall removes options, meta, and tables.
Code style
Follow WordPress Coding Standards: tabs for indentation, spaces inside parentheses function foo( $bar ), Yoda conditions where the project uses them, snake_case functions, Class_Name classes, and full <?php tags. Match the surrounding file's existing style over the global default when they conflict.
Red flags in review
add_action( 'wp_ajax_nopriv_...' )on anything that writes'permission_callback' => '__return_true'on a writing routeupdate_option( $_POST['name'], $_POST['value'] )— instant site takeover- user-controlled
role,ID, orwp_capabilitiesreachingwp_insert_user()/update_user_meta() unserialize( $_anything$wpdb->query( "... $var ..." )echo $with no escaping functionunlink()/includewith a request-derived pathbase64_decode+eval, nested decoders, long encoded blobs — seereferences/backdoor-indicators.mdregister_post_type()oninitwithout flushing rewrite rules on activationwp_schedule_event()without awp_next_scheduled()guard — duplicate events- Enqueuing on every page instead of conditionally
- Direct
$wpdbquery where a core API (WP_Query,get_posts,get_option) exists