# Wordpress

> Use when building or hardening WordPress sites or WooCommerce stores and treating WordPress as the product rather than just writing PHP — block themes with theme.json, plugins with block.json and proper hooks, wp-config security hardening, performance (object cache, asset loading, autoloaded options), and WP-CLI operations. NOT a Laravel app (that is `laravel`).

- Skill: `ericrisco/wordpress` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add ericrisco/wordpress`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/wordpress/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/wordpress

---


# WordPress

You are building the WordPress application layer — themes, plugins, the dashboard/CLI surface, security, and stores — for someone treating WordPress as the product. Know the difference between a `functions.php` hack and a real plugin. Refuse to paste secrets into the dashboard file editor. Pick block themes over page builders by default. Reach for `wp` before clicking through wp-admin.

This is not generic PHP. If there is no WordPress API in sight (value objects, Composer/PSR-4, PHPStan), that is [php](../php/SKILL.md). A Laravel app is [laravel](../laravel/SKILL.md).

## Version & runtime targeting

Target current. Stale version assumptions are the most common way WP code rots.

- **WordPress 6.9** (released 2025-12-02) is current; it refines speculative loading and supports PHP 8.5. WP 6.8 ("Cecil", 2025-04-15) added bcrypt password hashing and speculative loading. *Why: features below assume 6.6+ APIs (`wp_enqueue_block_style`, theme.json v3).*
- **PHP 8.4 is the recommended runtime for 2026.** WP 6.9 fully supports PHP 8.5; WP 6.8+ fully supports 8.4; WP 6.4+ supports 8.3. **Never target PHP 7.x or 8.0–8.2 for new code — all EOL.** *Why: writing for a dead runtime ships deprecation bugs the host will eventually refuse to run.*
- **theme.json v3** is the schema for new block themes (WP 6.6+). Locally set `WP_DEVELOPMENT_MODE` to `all` so theme.json edits are not cached for 30+ seconds. *Why: without it you will edit theme.json, see nothing change, and waste an hour.*

```php
// wp-config.php — local dev only, never on production
define( 'WP_DEVELOPMENT_MODE', 'all' );
define( 'WP_DEBUG', true );
define( 'SCRIPT_DEBUG', true );
```

## Decision: how heavy is the change?

Pick the lightest artifact that survives an update. The failure mode is everyone reaching for `functions.php` or editing files that get overwritten.

| You need to… | Artifact | Do NOT |
| --- | --- | --- |
| One-line tweak, site-specific behavior | mu-plugin in `wp-content/mu-plugins/` | dump it in the active theme's `functions.php` |
| Change look of a third-party theme | **child theme** | edit the parent theme — updates wipe it |
| Reusable feature, hooks, blocks, CPTs | **standalone plugin** | hide app logic in `functions.php` |
| Full custom front end | **block theme** + theme.json v3 | reach for a page builder for structural layout |
| Sell products | block theme + **WooCommerce** | hand-roll a cart |

## Block themes

A block theme is `theme.json` + HTML templates in `templates/` and `parts/`. Start from a v3 skeleton:

```json
{
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "color": {
      "palette": [
        { "slug": "base", "color": "#ffffff", "name": "Base" },
        { "slug": "contrast", "color": "#111111", "name": "Contrast" }
      ]
    },
    "typography": {
      "fluid": true,
      "fontFamilies": [
        { "fontFamily": "system-ui, sans-serif", "slug": "system", "name": "System" }
      ]
    },
    "layout": { "contentSize": "640px", "wideSize": "1100px" }
  },
  "styles": {
    "color": { "background": "var(--wp--preset--color--base)", "text": "var(--wp--preset--color--contrast)" }
  }
}
```

Templates resolve by hierarchy: `templates/index.html` is the fallback; `single.html`, `archive.html`, `page.html`, `404.html` override it. Reusable chunks live in `parts/` (`header.html`, `footer.html`).

Register patterns by dropping PHP-headered HTML in `patterns/`; they appear in the inserter automatically. Enqueue CSS **per block** so it only loads when the block renders:

```php
add_action( 'init', function () {
	wp_enqueue_block_style( 'core/group', array(
		'handle' => 'mytheme-group',
		'src'    => get_theme_file_uri( 'assets/blocks/group.css' ),
		'path'   => get_theme_file_path( 'assets/blocks/group.css' ),
	) );
} );
```

Rule: never hand-write `<link>`/`<script>` tags in templates — the browser cannot cache them and you lose dependency management. Always enqueue. And never edit a parent theme directly; make a child theme.

## Plugins

A plugin is a folder with a headered main file. That header is what makes it a plugin:

```php
<?php
/**
 * Plugin Name: Acme Books
 * Description: Registers the book CPT and a related block.
 * Version:     1.0.0
 * Requires PHP: 8.4
 * Requires at least: 6.9
 */
defined( 'ABSPATH' ) || exit; // never expose a direct hit
```

Hooks split two ways: **actions** fire at a point in execution (`add_action('init', …)`); **filters** transform a value and must `return` it (`add_filter('the_content', …)`). Confusing them is a top-five WP bug.

Register a block from a `block.json` — never duplicate metadata in PHP:

```php
add_action( 'init', function () {
	register_block_type( __DIR__ . '/build/related-books' ); // reads block.json
} );
```

Register a custom post type with explicit capabilities and labels:

```php
add_action( 'init', function () {
	register_post_type( 'book', array(
		'public'       => true,
		'show_in_rest' => true, // required for the block editor
		'supports'     => array( 'title', 'editor', 'thumbnail' ),
		'labels'       => array( 'name' => 'Books', 'singular_name' => 'Book' ),
	) );
} );
```

Every write path needs a **capability check + nonce**, and every output/query needs escaping/sanitizing. This is the line between a plugin and a vulnerability.

```php
// Bad — SQL injection, no auth
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_author = $id" );

// Good — capability, nonce, prepared statement
if ( ! current_user_can( 'edit_posts' ) ) { wp_die( 'nope' ); }
check_admin_referer( 'acme_save' );
$rows = $wpdb->get_results( $wpdb->prepare(
	"SELECT * FROM {$wpdb->posts} WHERE post_author = %d", $id
) );
echo esc_html( $rows[0]->post_title );
```

Sanitize on input (`sanitize_text_field`, `absint`), escape on output (`esc_html`, `esc_attr`, `esc_url`, `wp_kses` for allowed HTML).

## Security hardening

The single highest-impact habit is keeping core, plugins, and themes updated — 2024 saw 7,966 new ecosystem vulnerabilities, +34% YoY, and the vast majority hit known-vulnerable extensions. Baseline `wp-config.php` constants:

```php
define( 'DISALLOW_FILE_EDIT', true );  // kills dashboard Theme/Plugin editor; SFTP/Git unaffected
define( 'WP_AUTO_UPDATE_CORE', 'minor' );
define( 'FORCE_SSL_ADMIN', true );
```

Use **Application Passwords** (WP 5.6+) for REST/API machine auth — individually revocable, cannot log into `wp-login.php`, and the right answer when a script or [nextjs](../nextjs/SKILL.md) front end needs to talk to WP. Never reuse the admin password in a script.

For the full block — file-permission matrix (dirs 755 / files 644 / wp-config 600), `DISALLOW_FILE_MODS`, nginx/.htaccess security headers, login lockdown, XML-RPC and user-enumeration disabling, version-disclosure removal — see [references/hardening.md](references/hardening.md). Generic OWASP/threat-modeling not tied to the WP attack surface is [secure-coding](../secure-coding/SKILL.md).

## Performance

Two caches, different jobs: an **object cache** (Redis/Memcached via a drop-in) memoizes DB query results across requests; a **page cache** stores whole rendered HTML pages. You usually want both. Then:

- **Audit autoloaded options** first when wp-admin crawls — a few MB of autoloaded junk loads on *every* request. Find offenders with the SQL in the reference.
- Enqueue assets per block (above); defer/async non-critical scripts.
- Speculative loading (prerender/prefetch on hover) ships in 6.8+; tune it rather than bolting on a plugin.

Full Redis wiring, the autoload audit query, transients, and a WP-tied Core Web Vitals checklist are in [references/performance.md](references/performance.md). Generic CWV/load-testing methodology divorced from WordPress is the [performance](../performance/SKILL.md) sibling.

## WooCommerce

**HPOS** (High-Performance Order Storage) is the default order datastore for new installs since WooCommerce 8.2 — orders live in dedicated tables, not `wp_posts`. Enable/migrate and verify with WP-CLI:

```bash
wp wc hpos enable            # turn on custom order tables
wp wc cot sync               # backfill/sync existing orders
wp wc cot verify_cot_data    # confirm parity before flipping authority
```

Any plugin that touches orders **must declare HPOS compatibility**, or WooCommerce disables it in HPOS mode:

```php
add_action( 'before_woocommerce_init', function () {
	if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
		\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
			'custom_order_tables', __FILE__, true
		);
	}
} );
```

Store/checkout/product setup checklist and migration caveats: [references/woocommerce.md](references/woocommerce.md). A Shopify store is the [shopify](../shopify/SKILL.md) sibling.

## WP-CLI & local dev

`wp` is the operational backbone — scriptable, faster than the dashboard, and safe on serialized data.

- **Local stack:** `wp-env start` (Dockerized WP). Scaffold a block/plugin with `npx @wordpress/create-block acme-block`.
- **Migrations:** `wp search-replace` is serialization-safe — never run a raw SQL `REPLACE` on the DB, it corrupts serialized arrays. Dry-run first:

```bash
wp search-replace 'https://staging.example.com' 'https://example.com' --all-tables --dry-run
wp db export backup.sql        # always export before a real run
wp search-replace 'https://staging.example.com' 'https://example.com' --all-tables
```

- **Cron:** WP's pseudo-cron only fires on traffic. For reliable schedules, define `DISABLE_WP_CRON` true and run `wp cron event run --due-now` from real system cron.

## Anti-patterns

| Anti-pattern | Why it's wrong | Do instead |
| --- | --- | --- |
| App logic in `functions.php` | Dies with the theme; not portable | Standalone plugin or mu-plugin |
| Editing a parent theme / core files | Wiped on update | Child theme; hooks/filters |
| `query_posts()` | Breaks the main query, no pagination | `WP_Query` or `pre_get_posts` |
| `$wpdb->query("… $var …")` | SQL injection | `$wpdb->prepare()` with `%d`/`%s` |
| `<script src>`/`<link>` in templates | Not cached, no dependency graph | `wp_enqueue_script/style`, `wp_enqueue_block_style` |
| Secrets pasted in dashboard file editor | Logged, world-readable, version-control blind | `wp-config.php` constants + `DISALLOW_FILE_EDIT` |
| Page builder for structural layout | Bloat, lock-in, broken CWV | Block theme + patterns |
| Disabling auto-updates, never patching | 7,966 vulns/yr land on stale plugins | `WP_AUTO_UPDATE_CORE`; `wp plugin update --all` |
| Raw SQL `REPLACE` to change URLs | Corrupts serialized data | `wp search-replace` |
| Order-touching plugin without HPOS declaration | WooCommerce disables it | `FeaturesUtil::declare_compatibility` |

## verify.sh

`scripts/verify.sh <path>` statically scans theme/plugin/config code for these anti-patterns — committed DB creds or `AUTH_KEY`/salt literals, missing `DISALLOW_FILE_EDIT` when a `wp-config.php` is in scope, `eval(`/`base64_decode(`, `query_posts(`, unprepared `$wpdb` interpolation, and hardcoded `<script src=`/`<link rel="stylesheet">` in PHP templates. Read-only; exits 0 on a clean or empty target, non-zero on any FAIL so it can gate CI.

