190 lines
8.6 KiB
Markdown
190 lines
8.6 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).
|
|
- 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.
|
|
- 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.
|
|
- `$wpdb->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.
|