Files
agent-skills/skills/wordpress-plugin-conventions/SKILL.md
T
MalinandClaude Sonnet 5 1d24660303 skills(wordpress-plugin-conventions): merge security/lifecycle checks from wordpress/agent-skills
Per the 2026-08-15 skills.sh and autoskills.sh scans, both flagged
wordpress/agent-skills' wp-plugin-development module (Automattic-origin,
now WordPress-org-hosted) as high-value source material for iWP's plugin
skill: nonce+capability dual-check discipline, late escaping, prepared
SQL, cron idempotency, and uninstall-vs-deactivation guardrails.

Adapted (not copied) against real iWP plugin code in wp-plugins/:
- nonce+capability must-both framing, cited against
  class-iwp-cache-db-cleanup.php's actual AJAX handler
- late-escaping and wp_unslash()/explicit-key superglobal reading
- %i identifier-placeholder version gate (WP 6.2+, most iWP plugins
  floor at 6.0 or lower)
- new "Admin settings" section documenting the real Settings-API vs.
  AJAX-dashboard split across the suite, since the source's generic
  Settings-API-first prescription doesn't match roughly half of iWP's
  plugins
- new cron idempotency section citing the existing wp_next_scheduled()
  guard already used consistently in iwp-cache/iwp-woosales/iwp-booking
- new uninstall-vs-deactivation section flagging that only 3 of ~15
  plugins ship uninstall.php despite most creating options/tables
- new release-packaging checklist tied to iWP's actual IWP_Updater
  version-wiring convention (header/constant/updater param must agree)

Provenance noted inline with source URL. Left out the source's generic
architecture/Settings-API prescription and its detect_plugins.mjs
script (skill's house style is prose-only, no bundled scripts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 21:26:46 +02:00

343 lines
17 KiB
Markdown

---
name: wordpress-plugin-conventions
description: Use when writing or modifying a WordPress plugin's PHP code. Baseline structural/security conventions to follow without being told each time.
---
# WordPress Plugin Conventions
Baseline conventions for first-party WordPress plugin code. Follow these
without needing to be told in every brief.
## File structure
```
plugin-slug/
plugin-slug.php — main file: header, constants, bootstrap class
includes/
class-<prefix>-*.php — one class per concern, class-based not procedural
admin/ — admin-only UI (settings pages, dashboards)
assets/{css,js}/
languages/ — .pot/.po/.mo if the plugin is translatable
```
## Main file skeleton
```php
<?php
/**
* Plugin Name: ...
* Plugin URI: ...
* Description: ...
* Version: 1.0.0
* Author: ...
* Author URI: ...
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: plugin-slug
* Requires PHP: 7.4
*/
if (!defined('ABSPATH')) {
exit;
}
define('PREFIX_VERSION', '1.0.0');
define('PREFIX_PATH', plugin_dir_path(__FILE__));
define('PREFIX_URL', plugin_dir_url(__FILE__));
require_once PREFIX_PATH . 'includes/class-prefix-thing.php';
class Prefix_Main {
private static $instance = null;
public static function instance() {
if (null === self::$instance) { self::$instance = new self(); }
return self::$instance;
}
private function __construct() { /* hook registration only */ }
public static function activate() { /* create tables/options/dirs */ }
public static function deactivate() { /* reverse activate() side effects */ }
}
register_activation_hook(__FILE__, ['Prefix_Main', 'activate']);
register_deactivation_hook(__FILE__, ['Prefix_Main', 'deactivate']);
add_action('plugins_loaded', ['Prefix_Main', 'instance']);
```
Every included file starts with `if (!defined('ABSPATH')) exit;` — never
`define('ABSPATH', ...) &&` or any variant, exactly the guard-and-exit
form, so the file can never be requested directly over HTTP.
## Every file must pass `php -l` before you consider a task done
Not optional, not "probably fine" — actually run it, on every changed
file, every time. This is the cheapest possible check and catches a
meaningful fraction of real mistakes (typos, mismatched braces from a
find/replace, etc.) before they ever reach a live site.
## Security baseline
- Nonces on every form/AJAX action: `wp_nonce_field()` /
`check_admin_referer()` / `check_ajax_referer()`.
- Capability checks before any privileged action:
`current_user_can('manage_options')` (or the narrowest capability that
actually applies — don't default to `manage_options` for things a
lower-privileged role should legitimately be able to do).
- **Always both, never either alone.** A nonce proves the request came
from your own form/page (CSRF protection) — it says nothing about who
is allowed to make it. A capability check proves authorization — it
says nothing about whether the request was forged. Nonce-only lets a
forged/leaked-nonce request through if the attacker can get one
in-scope; capability-only lets a logged-in admin's browser be tricked
into firing the action via CSRF. This is already the live pattern —
`class-iwp-cache-db-cleanup.php`'s AJAX handler runs
`check_ajax_referer('iwp_cache_db_cleanup', 'nonce')` immediately
followed by `current_user_can('manage_options')`, never one without
the other. Match that shape, don't drop either check because "the
button is already hidden from non-admins" — client-side hiding is not
a server-side check.
- Escape on output, every time, using the context-correct function:
`esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()` — never raw-echo
anything that traces back to user input or the database without one of
these. Escape **late** — at the point of output, not when the value is
first read or stored — so the stored/cached copy stays raw and every
new rendering context gets its own correct escaping function.
- Sanitize on input: `sanitize_text_field()`, `absint()`,
`sanitize_email()`, etc. — appropriate to the expected shape of the
data, applied at the point the `$_POST`/`$_GET` value is first read.
Read superglobals by explicit key only — never loop over or dump the
whole `$_POST`/`$_GET` array — and run `wp_unslash()` before
sanitizing (WordPress adds slashes to superglobal values; sanitizing
before unslashing leaves stray backslashes in what gets stored).
- `$wpdb->prepare()` for every query with a variable in it — no string-
interpolated SQL, ever, no exceptions. The `%i` identifier placeholder
(for table/column names) only exists from WP 6.2 — most iWP plugins
declare `Requires at least: 6.0` or no floor at all, so don't rely on
`%i` without either raising the plugin's stated minimum or falling
back to an allow-listed identifier switch instead.
## Admin settings: Settings API vs. AJAX-backed dashboards
iWP plugins split roughly evenly between two real patterns — check which
one a given plugin already uses before adding a settings field, don't
introduce a third:
- **Classic Settings API** (`iwp-mailer`, `iwp-mailer-relay`,
`iwp-woosales`, `iwp-subscriptions`): `register_setting()` with a
`sanitize_callback`, an admin-post form, capability enforced by the
standard `option_page_capability_*` gate.
- **AJAX-backed dashboard** (`iwp-cache`, `iwp-security`, and most of
the newer plugins): a JS dashboard posts to `wp_ajax_*` handlers that
manually replicate the same discipline — `check_ajax_referer()` +
`current_user_can()` at the top of every handler, then
`update_option()` directly. `class-iwp-cache-db-cleanup.php` is the
canonical shape.
Either is fine; match whichever the plugin you're touching already uses.
## Database tables
If a plugin needs its own tables (not just options), use `dbDelta()`:
```php
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$wpdb->prefix}prefix_thing (...) {$charset};";
dbDelta($sql);
```
Track a `DB_VERSION` constant + a stored option so future plugin updates
can detect and re-run `dbDelta()` for schema changes.
## Cron: every scheduled hook needs a guard, and the callback must survive running twice
Every iWP plugin that schedules a recurring event already follows the
same guard — wrap `wp_schedule_event()` in a `wp_next_scheduled()` check,
never schedule unconditionally on every request:
```php
if (!wp_next_scheduled(self::CRON_HOOK)) {
wp_schedule_event(time(), 'daily', self::CRON_HOOK);
}
```
(see `iwp-cache/includes/class-iwp-cache-preload.php`,
`iwp-woosales/includes/class-iwp-woosales-cron.php`,
`iwp-booking/includes/class-iwp-booking-cron.php`). Without the guard,
every page load or re-activation re-schedules the event, and WP-Cron
silently accumulates duplicate instances of the same hook — they all
fire, so a "daily" job can end up running several times a day.
Beyond the schedule guard, the callback itself must tolerate running
late or twice — WP-Cron is a request-triggered pseudo-cron, not a
real-time scheduler; a low-traffic site can miss its window for hours,
and two concurrent requests can trigger the same due hook back-to-back.
Make the job naturally idempotent (e.g. "sync everything modified since
`last_sync_time`", safe to re-run) or guard the actual work with a
short-lived transient/option lock, not just the scheduling call.
Always pair `wp_schedule_event()` with the matching
`wp_clear_scheduled_hook()` — from `register_deactivation_hook()` if the
schedule shouldn't survive deactivation, or from `uninstall.php` if it
should survive deactivation but not a full uninstall (see next section).
## Uninstall vs. deactivation: decide data retention explicitly
Only three plugins in this suite currently ship an `uninstall.php`
(`iwp-security`, `iwp-woosales`, `informatiq-toolkit`), even though most
plugins create options, transients, and in several cases their own
database tables. That split isn't automatically wrong — WordPress's own
convention is that **deactivation should be reversible and
non-destructive** (stop cron, leave data alone so re-activating restores
prior state) while **uninstall is where actual cleanup belongs** — but
it should be a decision made per plugin, not an oversight.
When adding a new plugin, or touching an existing one's lifecycle code,
decide and make explicit which applies:
- **Deactivation** (`register_deactivation_hook`): stop cron
(`wp_clear_scheduled_hook()`) and nothing else. Data stays so toggling
the plugin off temporarily doesn't lose settings/history.
- **Uninstall** (`uninstall.php`, gated on
`if (!defined('WP_UNINSTALL_PLUGIN')) exit;` — prefer this over
`register_uninstall_hook()` for anything nontrivial, since a flat file
is simpler to keep in sync with the plugin's actual option/table list):
delete every option and transient the plugin created, drop every
custom table (`DROP TABLE IF EXISTS`), clear any cron still scheduled.
See `iwp-security/uninstall.php` and `iwp-woosales/uninstall.php` for
the shape.
A plugin that creates a custom table or a nontrivial option set and ships
neither an `uninstall.php` nor an explicit "intentionally retained,
because X" note is an oversight worth flagging, not a policy.
## PHP 7.4 compatibility
Unless a specific brief says otherwise, target PHP 7.4+ (a large fraction
of real WordPress hosting is still on 7.4). Avoid PHP 8-only syntax
(named arguments, enums, readonly properties, `match`). If a codebase
needs `str_contains`/`str_starts_with`/`str_ends_with` (PHP 8.0+) on an
environment that might run 7.4, guard with `function_exists()`
polyfills rather than assuming they exist.
## Drop-in source files (`advanced-cache.php`, `object-cache.php`) must be excluded from any normal plugin autoload/glob
If a plugin ships an `object-cache.php` or `advanced-cache.php` drop-in
(installed by copying a stub into `wp-content/` on activation), the PHP
file containing the *real logic* for that drop-in must **never** also be
loaded as a normal plugin include — not via an explicit `require`, and
not via a glob-based auto-loader that doesn't know to skip it. WordPress
core's own `wp-includes/cache.php` declares the same global `wp_cache_*()`
function names as the fallback used when no `object-cache.php` drop-in is
active; if the plugin's own copy of those functions loads a second time in
the same request (as a normal plugin file, in addition to — or instead
of — the standalone drop-in load), PHP fatals with "Cannot redeclare
function". Confirmed live during iWP Cache staging verification
(2026-08-02): a glob-based module auto-loader's exclusion list was written
before the object-cache class file existed, so it got swept in and
silently broke every activation. **If a plugin has any glob-based
autoload for its `includes/` directory, explicitly exclude every
drop-in-logic file by name** — don't rely on "it'll only load once" being
obviously true just because the code looks like a normal class file.
## `object-cache.php`/`advanced-cache.php` code runs standalone, before ABSPATH-based guards mean what they normally mean
The usual `if (!defined('ABSPATH')) exit;` guard doesn't prevent a
drop-in-logic file from loading twice in the same request, because by the
time it's `require`'d a second time (as a stray plugin include), ABSPATH
*is* already defined — WordPress has fully booted. A guard meant to stop
"direct access over HTTP" does nothing to stop "accidentally required
twice from two different code paths." Don't assume that guard is doing
more than it actually does.
## Never `add_action('some_hook', ..., $lower_priority)` from inside a callback already running on `some_hook`
A callback added to a LOWER (earlier) priority than the one currently
executing, from inside another callback on the SAME hook, will silently
never run in the current pass. `WP_Hook::apply_filters()` snapshots the
sorted priority keys once at the start of each `do_action()`/
`apply_filters()` call; adding a new priority mid-iteration only affects
a *future* call to that hook, and most hooks (`plugins_loaded`, `init`,
etc.) only fire once per request. There is no error, no warning — the
nested callback's own registration line executes fine, it's the callback
*inside* it that never runs.
**Confirmed real incident, copied into 3 separate plugins before being
caught**: every iWP-branded plugin's bootstrap instantiated its shared
license/update-checker class like this:
```php
private function __construct() {
add_action('plugins_loaded', ['IWP_Cache', 'instance']); // default priority 10
}
// ...inside instance()'s constructor:
add_action('plugins_loaded', function () {
new IWP_Updater([...]);
}, 5); // priority 5 -- LOWER than the 10 already executing -- never runs
```
Verified directly on a live site (`wp eval 'global $wp_filter; var_dump(isset($wp_filter["pre_set_site_transient_update_plugins"]));'` returned `false`) that the updater's own filter registration — and therefore all license validation and update checking — was silently dead on every plugin using this pattern, since the plugin was first built.
**The fix**: don't nest a lower-priority `add_action` inside a callback
already running on that hook at all. If the code you're deferring doesn't
actually need to wait for anything else on that same hook (check: does
its own constructor register hooks on *other*, later-firing actions? If
so, timing within the current hook doesn't matter) — just call it
directly, immediately, inline. Only use a nested `add_action` on the
*same* hook if the target priority is equal-or-later than the one
currently executing (still fragile — prefer restructuring to avoid the
nesting entirely).
## Don't build what WordPress core already gives you
Before writing custom code for: cron scheduling (`wp_schedule_event`),
REST endpoints (`register_rest_route`), settings UI
(`register_setting`/`add_settings_field`/`woocommerce_admin_fields` if
WooCommerce context), object caching (`wp_cache_*` functions), HTTP
requests (`wp_remote_get`/`wp_remote_post`, not raw `curl`) — check
whether a core API already does it. Reinventing these is both wasted
effort and a common source of subtle bugs core already solved correctly.
## Release packaging: version consistency before you zip anything
iWP plugins auto-update through `IWP_Updater`
(`wp-plugins/iwp-updater-client`, bundled per-plugin as
`includes/class-iwp-updater.php`), which reads the plugin's version from
a PHP constant, not from the zip filename or a release note. A release
is only as good as that constant being right, so before packaging:
1. **Three places must agree**: the `Version:` plugin header, the
`PREFIX_VERSION` constant defined right below the `ABSPATH` guard, and
the `'version' => PREFIX_VERSION` line passed into
`new IWP_Updater([...])`. Every plugin in the suite wires the
updater's version to that same constant, never a separate literal —
e.g. `iwp-cache.php` passes `'version' => IWP_CACHE_VERSION`,
`iwp-booking.php` passes `'version' => IWP_BOOKING_VERSION`. Bump the
header without bumping the constant (or vice versa) and
`IWP_Updater`'s `pre_set_site_transient_update_plugins` filter
compares the wrong number — customers either get nagged when already
current, or don't get notified of a real update.
2. **`plugin_slug` must match the catalog key** on iwp.es —
`wp-plugins/iwp-subscriptions`'s update-check endpoint looks the slug
up directly. Confirm against the actual product catalog, don't assume
it matches the plugin's directory name.
3. **Exclude dev artifacts from the shipped zip**: `.git/`, `.gitignore`
itself, `node_modules/`, test fixtures, and any internal-only
`README.md` content not meant for a customer (some plugin READMEs in
this suite are customer-facing, some — like
`iwp-updater-client/README.md` — are internal integration notes).
4. **Verify the packaged zip before shipping it**, don't trust a clean
build as proof it activates cleanly — deploy it with
`wordpress-plugin-staging-verification` against the persistent
`staging1` baseline. If this release is a rebrand/fork rather than a
version bump on an existing iWP plugin, run
`wordpress-plugin-rebrand`'s identifier-renaming/flattening step
first.
## Provenance
The nonce+capability pairing framing, late-escaping wording, `%i`
placeholder caveat, cron-idempotency guardrail, and uninstall-vs-
deactivation framing above were adapted from
[wordpress/agent-skills](https://github.com/wordpress/agent-skills)'
`wp-plugin-development` skill
(https://skills.sh/wordpress/agent-skills/wp-plugin-development, surfaced
by the 2026-08-15 skills.sh/autoskills.sh scans) — reworked against this
suite's actual code rather than copied verbatim. Its generic
"Settings-API-first" architecture prescription was deliberately not
imported wholesale: roughly half of iWP's plugins use an AJAX-backed
dashboard pattern instead (see "Admin settings" above), and prescribing
one true pattern would contradict the real, working code.