For WordPress developers · 2026 · Part 2

14 plugin habits AI can’t give you

July 2026 · ~14 min read

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:

// 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_scripts plus 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' survives sanitize_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, FileLogger and FakeLogger just implements LoggerInterface — 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 TABLE locks 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 / delete helpers 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.