For WordPress developers · 2026 · Part 2
14 plugin habits AI can’t give you
Working code is easy now — almost any AI writes it. These are the production habits from 12 years of plugin work: the ones that prevent slow admin screens, plugin conflicts and 1-star reviews after launch. Part 1 covered what the WordPress.org review team flags — this is everything else.
01 Skip the boilerplate. Layer it: Controller / Data / ViewModel
Ready-made boilerplates give you folders and dead weight — loader files, empty placeholders, one giant catch-all class where every feature lands. Folders aren’t architecture. What you actually need is three thin layers, each with one job:
- Controller — reads the request, checks nonce + permission, decides what happens. Never touches SQL.
- Data — the only layer allowed to touch
$wpdb. Change the table structure; nothing above notices. - ViewModel — shapes raw rows into exactly what the template or REST response needs. Presentation only — business rules stay out of it.
// inside the Controller — one line per layer:
$rows = $updates_db->for_order( $id );
// Data layer runs the SQL, returns raw rows
$model = UpdateViewModel::from_rows( $rows );
// ViewModel turns rows into clean output data
return rest_ensure_response( $model->to_array() );
When a bug appears you know which layer to open — request problem? Controller. Wrong data? Data. Ugly output? ViewModel.
02 Start every file with strict_types=1
By default PHP silently converts types — a string quietly becomes a number, true becomes 1. It “works” until one conversion goes wrong in production. Strict mode makes PHP refuse wrong types immediately.
✗ Without strict — the mistake ships, hidden
// this function expects a NUMBER:
function add_payment( float $amount ) { ... }
add_payment( $_POST['amount'] );
// form input is the STRING '10.5' —
// converted silently. Mistake ships hidden.
✓ With strict — caught at your desk
<?php declare( strict_types=1 );
add_payment( $_POST['amount'] );
// TypeError: expects float, string given
// so you convert it once, where it arrives:
add_payment( (float) $_POST['amount'] );
Strict types catch the wrong type, not bad values —
'banana'still needs validation (Habit 05). Together they move errors from production to development.
03 PSR-4 + a namespace — stop hand-loading classes
Three steps. 1) One rule in composer.json: “every class starting Awts\ lives under src/”. 2) Name the file after the class, the folder after the namespace. 3) Just use the class — Composer requires the file for you. Bonus: namespaced classes can never clash with another plugin’s.
✗ Before — load every class by hand
require_once 'includes/class-admin.php';
require_once 'includes/class-settings.php';
// ...28 more, updated by hand forever
✓ After — one rule: namespace = folder
// composer.json (note the doubled backslash)
"autoload": { "psr-4": { "Awts\\": "src/" } }
// src/Admin/Settings.php
namespace Awts\Admin;
class Settings {}
// anywhere — loads itself, no require:
new \Awts\Admin\Settings();
Namespaces cover your classes. Options, hooks and transients still need the string prefix — that one’s in Part 1.
04 Same class on every page? Check the screen first
admin_init fires on every admin request. Validate the screen first — via current_screen, or load-{$hook_suffix}, which fires only when that one screen opens. Prefer named callbacks; anonymous ones are difficult to remove.
✗ Before — runs its queries on every screen
add_action( 'admin_init',
[ $reports, 'load_stats' ] );
✓ After — validate the screen, then work
add_action( 'current_screen', 'awts_maybe_load' );
function awts_maybe_load( $screen ) {
if ( 'toplevel_page_awts' !== $screen->id )
return; // not my page, exit
( new Awts_Reports() )->load_stats();
}
// scripts: same rule, via the hook arg
add_action( 'admin_enqueue_scripts', 'awts_js' );
function awts_js( $hook ) {
if ( 'toplevel_page_awts' === $hook )
wp_enqueue_script( 'awts-app' );
}
Front end:
wp_enqueue_scriptsplus a check that your shortcode or block is actually on the page. Every feature loads only where it’s used.
05 The order: nonce → sanitize → validate → escape
Four steps, fixed order, each answering one question. Nonce: is this request genuine? Sanitize: is the input the right shape? Validate: does it make business sense? Escape: is it safe to print, here?
// 1 · NONCE — genuine request?
if ( ! wp_verify_nonce( $nonce, 'awts_st' ) )
return;
// 2 · SANITIZE — right shape?
$status = sanitize_key(
wp_unslash( $_POST['status'] ?? '' ) );
// 3 · VALIDATE — makes sense?
if ( ! in_array( $status,
[ 'open', 'closed' ], true ) ) return;
// 4 · ESCAPE — safe to print?
echo esc_html( $status );
For normal admin forms,
check_admin_referer()is the common shortcut for step 1. And remember'banana'survivessanitize_key()— only step 3 protects your data.
06 Use vanilla JS, not jQuery
Modern block themes, the block checkout and new admin UIs don’t use jQuery — it adds another dependency and is often unnecessary today, plus ~30 KB every visitor downloads before your 3 lines can run. Vanilla JS loads nothing and runs instantly.
✗ jQuery — a dependency you don’t control
$( '.reply' ).on( 'click', send );
// Uncaught TypeError: $ is not a function
// theme didn't load jQuery, or loaded its
// own version after yours. Plus ~30 KB +
// one extra request, on every page view.
✓ Vanilla — 0 KB, nothing to conflict with
panel.addEventListener( 'click', ( e ) => {
// only react to clicks on .reply,
// exactly like the jQuery version:
if ( ! e.target.closest( '.reply' ) ) return;
fetch( url, { method: 'POST',
body: JSON.stringify( data ) } )
.then( r => r.json() ).then( render );
} );
The browser does natively what jQuery was for in 2012 — selectors, events, AJAX. Zero dependencies is the one version that can’t clash.
07 Theme with CSS variables, not static colours
Hard-code a colour in 40 places and every store owner who wants to match their brand files a support ticket — or fights you with !important. Put it in one variable and they override one line; your whole UI follows.
✗ Before — hard-coded everywhere
.awts-badge { color: #2563eb; }
.awts-btn { background: #2563eb; }
/* ...38 more places... */
✓ After — one knob to override
:root { --awts-accent: #2563eb; }
.awts-badge { color: var(--awts-accent); }
.awts-btn { background: var(--awts-accent); }
/* store owner's theme: one line */
:root { --awts-accent: #b91c1c; }
Prefix the variables too (
--awts-*) — CSS custom properties share one global namespace, same as options.
08 Docblocks on public and non-obvious functions
Tiny private getters don’t need one. Public methods and anything non-obvious do — the docblock carries what code can’t say: intent, units, edge cases. And the next developer confused by your code is usually you, eight months later.
✗ Before — what does it expect? Nobody knows
function awts_total( array $items ): float {
return (float) array_sum( $items );
}
✓ After — the gotchas are written down
/**
* Sum line-item totals for one order.
* Amounts are tax-INCLUSIVE; refunds
* arrive as negatives on purpose.
* @param float[] $items Line amounts.
* @return float Order total.
*/
function awts_total( array $items ): float {
return (float) array_sum( $items );
}
The middle two lines do the real work — tax-inclusive, negatives allowed. That’s a future bug report prevented, not decoration.
09 Type every parameter and the return
Different job than the docblock: a type in the signature is enforced by PHP itself. Pass the wrong thing and it fails loudly at the call — instead of a bad value travelling deep into your code and breaking something far from the real cause.
function assign_ticket(
int $order_id, // whole number only
float $sla_hours, // 4.5 is fine
array $tags, // list, not a string
WP_User $agent // a real user object
): bool { // promises true/false
// ...
}
declare( strict_types=1 ); // Habit 02
assign_ticket( '99', '4', 'vip', 7 );
// TypeError — all four caught, instantly
Rule of thumb: types say what, docblocks say why. Together with
strict_types=1(Habit 02), PHP becomes your first reviewer.
10 Pass dependencies in — swap without editing core
If Mailer creates its own logger with new inside, changing the logger means editing Mailer — and testing it means real log writes. Accept the dependency through the constructor, typed as an interface, and you swap versions from outside. The class itself never changes.
class Mailer {
function __construct( LoggerInterface $log ) {
$this->log = $log;
}
}
// production — log to the database:
new Mailer( new DbLogger() );
// one client needs files instead —
// no edit to Mailer, just hand it another:
new Mailer( new FileLogger() );
// unit tests — no real writes at all:
new Mailer( new FakeLogger() );
Each of
DbLogger,FileLoggerandFakeLoggerjust implementsLoggerInterface— the interface is what makes them interchangeable. This is one reason maintaining 217 unit tests stays practical.
11 Unit tests: 80+ files, and the suite names what broke
My plugin is 80+ files. A refactor breaks something between the cache and the logger — no fatal error, just wrong behaviour. Finding that by reading code is an evening. Running the suite is ten seconds:
$ vendor/bin/phpunit
............F.............. 217 tests
FAILED CacheTest::test_cache_miss_is_logged
Expected logger to receive 'miss',
got nothing.
✓ The test that caught it — 6 lines
function test_cache_miss_is_logged() {
$log = new FakeLogger();
$cache = new Cache( $log ); // Habit 10
$cache->get( 'missing_key' );
$this->assertSame( ['miss'], $log->events );
}
The failure names the class, the method and the exact connection that broke — before any customer finds it. Start with the code that touches money and permissions.
12 Server caching: wp_cache or transient?
wp_cache: same function called from many places on one page load? The DB query runs once, the rest reuse the saved result. Without a persistent object cache it lives only for that page load. Transient: keep something until a set time — like an API response — saved in the DB.
✓ wp_cache — same function called 5× = 1 query
$rows = wp_cache_get( $key, 'awts' );
if ( false === $rows ) {
$rows = $wpdb->get_results( /* heavy */ );
wp_cache_set( $key, $rows, 'awts', 300 );
}
// vanishes when this page load ends —
// unless the host has an object cache
✓ transient — must survive page loads
$rates = get_transient( 'awts_fx' );
if ( false === $rates ) {
$rates = wp_remote_get( /* slow API */ );
set_transient( 'awts_fx',
$rates, HOUR_IN_SECONDS );
}
// saved in the database — keeps the API
// response until the time runs out
Both: delete the entry whenever the real data changes, or you’ll debug “wrong” data that’s just your own old cache.
12 · The visitor’s side: cookies are for one visitor’s state
A cookie is written into the visitor’s browser and comes back with every request. Good for remembering things about this person — not for caching data. The visitor can read and edit it, so never store sensitive data in plain form; encrypted or signed values only.
✓ Cookie — remember a visitor’s choice
setcookie( 'awts_notice_off', '1',
time() + MONTH_IN_SECONDS, COOKIEPATH );
// on later visits:
if ( isset( $_COOKIE['awts_notice_off'] ) )
return; // this visitor dismissed it
Never trust a cookie for anything sensitive — the visitor can edit it. Logged-in users → user meta. Preferences only your JS needs →
localStorage, no server round-trip at all.
13 Index the columns you search — even on core tables
Real case: find an order by tracking number in a 2M-row wp_postmeta. Core indexes meta_key but not meta_value — so this scans the whole table, every time:
✗ Existing table — full scan, seconds
SELECT post_id FROM wp_postmeta
WHERE meta_key = '_awts_tracking'
AND meta_value = 'TRK-4471';
✓ One prefix index — milliseconds
ALTER TABLE wp_postmeta ADD INDEX
awts_kv ( meta_key(191), meta_value(32) );
-- the column is too big to index fully,
-- so index just the first characters.
-- 32 fits tracking codes — size the
-- prefix to YOUR data, don't copy it.
✓ Your own table — index for the query
-- one key that both filters AND sorts:
KEY order_created ( order_id, created_at )
ALTER TABLElocks big tables — run it off-peak. For permanent needs, a custom lookup table beats indexing core (exactly what HPOS did).
14 Built-in APIs first — own queries only when needed
wc_get_order(), WP_Query and the $wpdb helpers give you caching, hooks and compatibility with WooCommerce’s new order storage for free — raw SQL gives you none of it.
✓ 90% of cases — use the built-ins
$order = wc_get_order( $order_id );
$q = new WP_Query( [
'post_type' => 'product',
'posts_per_page' => 20, // never -1
'paged' => $paged,
'no_found_rows' => true, // skips the
// total-count query when not needed
] );
// and avoid post__not_in — full scan
✓ Own table — when data doesn’t fit post/meta
$wpdb->insert( $t, [ 'order_id' => $id,
'event' => 'breach' ], [ '%d', '%s' ] );
$rows = $wpdb->get_results( $wpdb->prepare(
"SELECT id, event FROM %i WHERE
order_id = %d LIMIT %d OFFSET %d",
$t, $id, 20, $offset ) );
-- plus: KEY order_id (order_id)
insert/update/deletehelpers for writes,prepare()+%i(WP 6.2+) for reads, paginated and indexed. Earn the right to write raw SQL.
Adopt the habits, skip the scars
None of these came from a reviewer — they came from slow admin screens, midnight conflict debugging and support tickets I never want to read again. Which habit do you disagree with? The jQuery one always starts a fight — tell me below.