--- 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--*.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 prepare()` for every query with a variable in it — no string- interpolated SQL, ever, no exceptions. ## 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. ## 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.