8.6 KiB
name, description
| name | description |
|---|---|
| wordpress-plugin-conventions | 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
/**
* 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 tomanage_optionsfor 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/$_GETvalue 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():
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:
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.