WordPress plugin development skills for Claude Code — GitHub flow (scoped commits, PR, hotfix, revert), WooCommerce extensions, plugin testing (PHPUnit/Brain\Monkey/redirect harness), coding standards (PHPCS/WPCS), CI/QA triage, PHPStan stubs scaffolding, build tools (@wordpress/scripts), background processing (Action Scheduler), multisite, i18n workflow, database (dbDelta/migrations), email templates, Freemius SDK, plugin audit, release versioning, WP.org submission/SVN deploy, WP admin browser automation, and guided tours (Driver.js).
Use when interacting with a WordPress admin panel via Chrome DevTools MCP — logging in, creating users, navigating menus, submitting forms, or performing any data operations (create/update/delete) through the browser. Also use when needing a temporary admin user for testing instead of the main admin account.
# WordPress Admin via Browser (Chrome DevTools MCP)
## Core Rules — Non-Negotiable
1. **Never touch the main admin user.** Always create a temporary admin for testing.
2. **All data operations go through the browser UI.** No WP-CLI, no direct DB, no REST API calls to mutate data — use WordPress forms.
3. **Navigate via menus, not hardcoded URLs.** Click the menu item; don't jump straight to `/wp-admin/users.php?action=...`.
4. **Use `fill_form` + click for all inputs.** Never skip the form and post directly.
---
## Step 1 — Log In
```js
// POST to wp-login.php via fetch (fastest, no UI needed for login itself)
async () => {
const res = await fetch('/wp-login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
log: 'YOUR_USER',
pwd: 'YOUR_PASS',
'wp-submit': 'Log In',
redirect_to: '/wp-admin/',
testcookie: '1',
}),
credentials: 'include',
redirect: 'follow',
});
return { ok: res.ok, url: res.url };
}
```
Login via `fetch` is acceptable because it's a pure auth step — no data mutation.
---
## Step 2 — Create a Temporary Admin User
**Always create a temp user before any testing that requires admin actions.**
Navigate to Users → Add New via menu clicks, not direct URL.
```
Admin menu → Users → Add New
```
Fill the form using `fill_form`:
| Field | Value |
|-------|-------|
| Username | `tmp_admin_<timestamp>` |
| Email | `tmp+<timestamp>@example.com` |
| First Name | `Temp` |
| Last Name | `Admin` |
| Role | `Administrator` |
| Password | strong generated password |
| Send notification | unchecked |
After testing: **delete the temp user** via Users list → hover → Delete.
---
## Step 3 — Get a REST Nonce (for read-only API calls only)
```js
async () => {
return fetch('/wp-admin/admin-ajax.php?action=rest-nonce', {
credentials: 'include',
}).then(r => r.text());
}
```
Use nonce only for **GET** requests to read data. All mutations go through WP forms.
---
## Browser Interaction Rules
### Navigation
```
✅ Click: Admin menu → Submenu item
❌ Never: navigate_page to hardcoded /wp-admin/edit.php?post_type=...
```
### Forms
```
✅ fill_form on visible fields → click Submit button
❌ Never: fetch POST directly to admin-post.php / admin-ajax.php for data changes
❌ Never: wp eval or wp post create via CLI for browser-visible operations
```
### Menus
Always wait for the page to load after each menu click before interacting with the next element.
---
## Data Operation Checklist
| Operation | Method |
|-----------|--------|
| Create post/page | Posts → Add New → fill form → Publish |
| Update user | Users → find user → Edit → fill form → Update |
| Delete item | List view → hover row → Delete (confirm dialog) |
| Change setting | Settings menu → fill field → Save Changes |
| Install plugin | Plugins → Add New → Search → Install → Activate |
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Using main admin for destructive tests | Create temp admin first |
| Navigating directly to `?action=delete&id=X` | Use list UI → Delete link |
| Using `wp user create` CLI to seed browser session | Use Add New User form |
| Skipping `fill_form` and posting via fetch | Always fill the visible form |
| Leaving temp admin after testing | Delete via Users list when done |
---
## JS State Verification
Use `evaluate_script` to inspect JavaScript state without touching the UI.
### Check scripts loaded + globals present
```js
() => ({
// Verify a localized WP global is available
myPlugin: typeof window.MY_PLUGIN,
keys: Object.keys(window.MY_PLUGIN || {}),
// Verify a library bundle loaded
driverLoaded: !!window.driver?.js?.driver,
// Check current URL
url: location.href,
hash: location.hash,
})
```
If `typeof window.MY_PLUGIN === 'undefined'` the script may not be enqueued for this screen, or the page is in maintenance mode (`document.title === 'Maintenance'`).
### SPA pages — always wait for React to render
```js
// Wrong: query DOM immediately after navigate
() => document.querySelector('.spa-element') // returns null
// Right: wrap in setTimeout
() => new Promise(r => setTimeout(() => r(
!!document.querySelector('.spa-element')
), 2000))
```
### Verify CSS selectors before committing
```js
() => {
const selectors = [
'#my-id',
'.class-one.class-two',
'.border-\\[\\#F0EDFB\\]', // Tailwind escaped
];
return selectors.map(s => ({
selector: s,
found: !!document.querySelector(s),
count: document.querySelectorAll(s).length,
}));
}
```
**Always test on the page where the selector is expected to exist.** A selector for the orders page returns `false` on the dashboard — that's not a bug.
### localStorage testing
```js
// Clear feature flags / completion state before testing
() => {
Object.keys(localStorage)
.filter(k => k.startsWith('myplugin_'))
.forEach(k => localStorage.removeItem(k));
return 'cleared';
}
// Read a specific key after an action
() => localStorage.getItem('myplugin_feature_completed') // "true" or null
```
### Detect maintenance mode / redirect loop
If `navigate_page` lands on a blank page or unexpected content:
```js
() => ({ title: document.title, url: location.href })
// "Maintenance" title = WP in maintenance mode (.maintenance file or plugin)
// Redirected to /wp-login.php = session expired — re-login via fetch
```
### Re-login when session expires
```js
async () => {
const res = await fetch('/wp-login.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
log: 'admin', pwd: 'admin',
'wp-submit': 'Log In',
redirect_to: '/wp-admin/',
testcookie: '1',
}),
credentials: 'include',
redirect: 'follow',
});
return { ok: res.ok, url: res.url };
}
```
---
## References
- [Chrome DevTools MCP tools](references/chrome-devtools-tools.md) — full tool params, interaction sequence template, priority rules
- [WordPress admin navigation](references/wp-admin-navigation.md) — menu paths, form submit patterns, success strings
- [Browser debug patterns](references/browser-debug-patterns.md) — console error capture, network request inspection, evaluate_script debug snippets, screenshot strategy, PHP error detection
---
## Cleanup
After every test session:
1. Log in as main admin (or existing admin).
2. Users → find all `tmp_admin_*` users → bulk delete.
3. Do **not** reassign their content (temp users have no content).Use when implementing background jobs, queued tasks, or long-running processes in a WordPress plugin — Action Scheduler (WooCommerce queue), WP_Background_Process (Delicious Brains pattern), WP Cron, batch processing with progress tracking, or retry/error handling for async jobs.
# WordPress Background Processing
Implement background jobs and queued tasks in WordPress plugins. Three primary tools with distinct trade-offs: Action Scheduler (persistent, battle-tested), `WP_Background_Process` (lightweight, no DB table), WP Cron (built-in, unreliable timing).
## When to use
- "Process a large batch of items in the background", "queue a long-running task".
- "Set up Action Scheduler", "use WooCommerce queue".
- "Implement WP_Background_Process", "chunked batch import".
- "Background email sending", "async API calls".
- "Fix a WP Cron job not firing", "make scheduled tasks reliable".
**Not for:** One-off scheduled events (use `wp_schedule_single_event`). REST API async patterns — use `wp-rest-api`.
## Method
### 1. Choose the right tool
| Tool | Persistent | Retry | Progress | Requires | Best for |
|---|---|---|---|---|---|
| Action Scheduler | ✅ DB table | ✅ Configurable | Via hooks | AS or WC | Reliable multi-step queues |
| WP_Background_Process | ✅ Options API | ❌ Manual | Via option | ~5KB class | Simple batches, no WC dep |
| WP Cron | ❌ (transient) | ❌ | ❌ | Nothing | Maintenance, low-priority tasks |
**Rule of thumb:** WooCommerce already installed → Action Scheduler. Simple background batch → `WP_Background_Process`. Periodic cleanup task → WP Cron.
### 2. Action Scheduler
Bundled with WooCommerce. Can also be installed as a standalone library.
**Standalone install:**
```bash
composer require woocommerce/action-scheduler
```
```php
require_once plugin_dir_path( __FILE__ ) . 'vendor/woocommerce/action-scheduler/action-scheduler.php';
```
**Schedule and handle actions:**
```php
// Schedule a single action (fires once ASAP)
as_enqueue_async_action( 'my_plugin_process_item', [ 'item_id' => 123 ], 'my-plugin' );
// Schedule a recurring action
as_schedule_recurring_action( time(), HOUR_IN_SECONDS, 'my_plugin_hourly_sync', [], 'my-plugin' );
// Schedule a single action in the future
as_schedule_single_action( time() + 300, 'my_plugin_delayed_job', [ 'batch' => 1 ], 'my-plugin' );
// Handle the action
add_action( 'my_plugin_process_item', function( $item_id ) {
$result = my_plugin_do_work( $item_id );
if ( is_wp_error( $result ) ) {
// AS will retry on exception; throw to trigger retry
throw new \Exception( $result->get_error_message() );
}
} );
```
**Batch queue (fan-out pattern):**
```php
function my_plugin_queue_all_items() {
$items = get_posts( [ 'post_type' => 'my_type', 'posts_per_page' => -1, 'fields' => 'ids' ] );
foreach ( $items as $id ) {
// Skip if already scheduled
if ( ! as_has_scheduled_action( 'my_plugin_process_item', [ 'item_id' => $id ], 'my-plugin' ) ) {
as_enqueue_async_action( 'my_plugin_process_item', [ 'item_id' => $id ], 'my-plugin' );
}
}
}
```
**Cancel scheduled actions:**
```php
as_unschedule_action( 'my_plugin_hourly_sync', [], 'my-plugin' );
as_unschedule_all_actions( 'my_plugin_process_item', [], 'my-plugin' );
```
**Monitor via WP-CLI:**
```bash
wp action-scheduler list --group=my-plugin --status=pending
wp action-scheduler run --group=my-plugin
```
### 3. WP_Background_Process
Lightweight 2-class library using WP options + transients for queue state. No extra DB table.
```bash
composer require deliciousbrains/wp-background-processing
```
**Extend the class:**
```php
class My_Plugin_Batch_Process extends WP_Background_Process {
protected $action = 'my_plugin_batch'; // Unique key — used for cron and option names
protected function task( $item ) {
// Process one item. Return false to remove from queue; return $item to re-queue.
$result = my_plugin_process( $item['id'] );
if ( is_wp_error( $result ) ) {
// Log and skip — returning $item would re-queue endlessly
error_log( 'my-plugin: failed item ' . $item['id'] . ': ' . $result->get_error_message() );
return false;
}
return false; // Done — remove from queue
}
protected function complete() {
parent::complete();
update_option( 'my_plugin_batch_complete', time() );
do_action( 'my_plugin_batch_complete' );
}
}
```
**Push items and dispatch:**
```php
$process = new My_Plugin_Batch_Process();
$items = get_ids_to_process();
foreach ( $items as $id ) {
$process->push_to_queue( [ 'id' => $id ] );
}
$process->save()->dispatch();
```
**Check status:**
```php
if ( $process->is_queue_empty() ) { /* done */ }
```
### 4. WP Cron
Built-in. Fires on page load — unreliable on low-traffic sites. Use for non-critical periodic tasks.
```php
// Register custom interval
add_filter( 'cron_schedules', function( $schedules ) {
$schedules['every_15_minutes'] = [
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __( 'Every 15 Minutes', 'my-plugin' ),
];
return $schedules;
} );
// Schedule on activation; clear on deactivation
register_activation_hook( __FILE__, function() {
if ( ! wp_next_scheduled( 'my_plugin_cron_job' ) ) {
wp_schedule_event( time(), 'every_15_minutes', 'my_plugin_cron_job' );
}
} );
register_deactivation_hook( __FILE__, function() {
wp_clear_scheduled_hook( 'my_plugin_cron_job' );
} );
// Handle
add_action( 'my_plugin_cron_job', function() {
my_plugin_do_maintenance();
} );
```
**Make WP Cron reliable on low-traffic sites:** Set up a real system cron to call `wp-cron.php` and disable the page-load trigger:
```bash
# System cron (every 5 minutes)
*/5 * * * * wget -q -O - https://example.com/wp-cron.php?doing_wp_cron >/dev/null 2>&1
```
```php
// wp-config.php
define( 'DISABLE_WP_CRON', true );
```
### 5. Progress tracking
Track batch progress for admin UI display:
```php
// Increment progress in task()
protected function task( $item ) {
$progress = get_option( 'my_plugin_batch_progress', [ 'done' => 0, 'total' => 0 ] );
// ... do work ...
$progress['done']++;
update_option( 'my_plugin_batch_progress', $progress );
return false;
}
// Poll via REST or AJAX
add_action( 'wp_ajax_my_plugin_batch_progress', function() {
wp_send_json_success( get_option( 'my_plugin_batch_progress', [ 'done' => 0, 'total' => 0 ] ) );
} );
```
### 6. Error handling patterns
Action Scheduler retries on uncaught exceptions. Control retry behaviour:
```php
// Fail permanently (no retry)
ActionScheduler_Logger::instance()->log( $action_id, 'Permanent failure: ' . $reason );
throw new ActionScheduler_InvalidActionException( $reason );
// Retry with delay (reschedule from handler)
as_schedule_single_action( time() + 300, 'my_plugin_process_item', $args, 'my-plugin' );
return; // Don't throw — AS won't auto-retry this run
```
## Notes
- Action Scheduler stores pending/failed actions in `{prefix}actionscheduler_actions` table — visible in WC → Status → Scheduled Actions. Check there first when debugging stuck jobs.
- WP Cron events do **not** persist across deactivation — always clear on `register_deactivation_hook`.
- Background processes that modify many posts/options should run in small chunks (50–100 items) to avoid timeout and memory limits. Use `$process->memory_exceeded()` and `$process->time_exceeded()` checks from `WP_Background_Process` to self-limit.
- On multisite: Action Scheduler is per-site. For network-wide jobs, run from the main site or loop via `switch_to_blog()`.Use when setting up or debugging the JavaScript/CSS build pipeline for a WordPress plugin — @wordpress/scripts, webpack, Vite, block editor assets, asset enqueuing with .asset.php files, compiling Sass/PostCSS, or reusing a JS/CSS library bundled by a dependency plugin (e.g. EDD/WooCommerce) instead of vendoring your own. Not for block registration logic — use the official wp-block-development skill.
# WordPress Plugin Build Tools
Configure and operate the JS/CSS build pipeline for WordPress plugins: `@wordpress/scripts` (webpack-based), Vite alternative, asset manifest handling, and correct enqueuing with the generated `.asset.php` dependency file.
## When to use
- "Set up `@wordpress/scripts`", "configure webpack for my plugin".
- "Build blocks and admin scripts", "compile Sass for a plugin".
- "Why isn't my JS loading?", "fix asset enqueue with versioned hash".
- "Switch from @wordpress/scripts to Vite".
- "Set up separate entry points for front-end vs admin vs block editor".
**Not for:** Block registration, `block.json` structure, or Gutenberg API — use the official `wp-block-development` skill. PHP-side REST API — use `wp-rest-api`.
## Method
### 1. Install @wordpress/scripts
```bash
npm install --save-dev @wordpress/scripts
```
**`package.json`:**
```json
{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"lint:js": "wp-scripts lint-js",
"lint:css": "wp-scripts lint-style"
}
}
```
Default entry point: `src/index.js` → `build/index.js` + `build/index.asset.php`.
### 2. Multiple entry points
Create `webpack.config.js` at plugin root to override the default entry:
```js
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
module.exports = {
...defaultConfig,
entry: {
'admin': './src/admin/index.js',
'frontend': './src/frontend/index.js',
'block-editor': './src/blocks/index.js',
'style-admin': './src/admin/admin.scss',
},
};
```
Outputs:
```
build/
├── admin.js + admin.asset.php
├── frontend.js + frontend.asset.php
├── block-editor.js + block-editor.asset.php
└── style-admin.css (no .asset.php for pure CSS entry)
```
### 3. Enqueue assets correctly
The `.asset.php` file contains the dependency array and a content hash — always use it.
```php
function my_plugin_enqueue_admin_assets() {
$asset_file = plugin_dir_path( __FILE__ ) . 'build/admin.asset.php';
if ( ! file_exists( $asset_file ) ) return;
$asset = include $asset_file;
wp_enqueue_script(
'my-plugin-admin',
plugin_dir_url( __FILE__ ) . 'build/admin.js',
$asset['dependencies'], // auto-includes wp-element, wp-i18n, etc.
$asset['version'], // content hash — cache busted on change
true // in footer
);
wp_enqueue_style(
'my-plugin-admin-style',
plugin_dir_url( __FILE__ ) . 'build/style-admin.css',
[],
$asset['version']
);
// Pass PHP data to JS
wp_localize_script( 'my-plugin-admin', 'myPluginData', [
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'my_plugin_action' ),
'apiUrl' => rest_url( 'my-plugin/v1/' ),
] );
}
add_action( 'admin_enqueue_scripts', 'my_plugin_enqueue_admin_assets' );
```
For block assets registered via `block.json` — do NOT manually enqueue; WP handles it:
```php
register_block_type( __DIR__ . '/build/my-block' ); // reads block.json automatically
```
### 4. Sass / PostCSS
`@wordpress/scripts` supports Sass out of the box (via webpack sass-loader). No extra config needed for `.scss` files imported in JS:
```js
// src/admin/index.js
import './admin.scss';
```
For standalone `.scss` entry (CSS-only build):
```js
// webpack.config.js entry
entry: {
'admin-styles': './src/admin/admin.scss',
}
```
Output: `build/admin-styles.css` (no `.asset.php` generated for pure CSS entries — hardcode version or use `filemtime()`).
PostCSS config (`postcss.config.js`) is picked up automatically if present:
```js
module.exports = {
plugins: {
autoprefixer: {},
'postcss-custom-properties': {},
},
};
```
### 5. Vite alternative
For non-block plugins where `@wordpress/scripts` dependency auto-detection isn't needed:
```bash
npm install --save-dev vite @vitejs/plugin-legacy
```
**`vite.config.js`:**
```js
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';
export default defineConfig( {
plugins: [ legacy( { targets: [ 'defaults', 'ie >= 11' ] } ) ],
build: {
outDir: 'build',
rollupOptions: {
input: {
admin: 'src/admin/index.js',
frontend: 'src/frontend/index.js',
},
output: {
entryFileNames: '[name].js',
chunkFileNames: '[name]-[hash].js',
assetFileNames: '[name].[ext]',
},
},
},
} );
```
**Caveat:** Vite does not generate `.asset.php`. Manage WP script dependencies manually, or use `wp-scripts` for anything that imports `@wordpress/*` packages (they must be `externals`).
### 6. Externals — don't bundle WordPress packages
`@wordpress/scripts` automatically externalises all `@wordpress/*` imports (they're on the global `wp` object). If you use a custom webpack config, preserve this:
```js
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
// defaultConfig already has the correct externals — spread it, don't replace it
module.exports = { ...defaultConfig, entry: { ... } };
```
Never `import { useState } from 'react'` in WP code — import from `@wordpress/element`:
```js
import { useState, useEffect } from '@wordpress/element';
```
### 7. `.gitignore` and production builds
```gitignore
node_modules/
build/
```
Include `build/` in the SVN/release zip but NOT in git. In the release workflow (`wp-plugin-release` + `wp-org-submission`), run `npm run build` before zipping.
CI build step for GitHub Actions:
```yaml
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
```
### 8. Reuse a dependency's bundled library instead of vendoring your own
When a plugin you already hard-depend on (e.g. EDD, WooCommerce) ships a front-end library you need — Tom Select, Select2, Choices, flatpickr — enqueue *its* copy rather than vendoring a second one. Saves bundle size and a maintenance surface, at the cost of coupling to the host's file paths.
```php
function my_plugin_enqueue_tom_select(): bool {
if ( ! defined( 'EDD_PLUGIN_URL' ) ) {
return false; // dependency not active — caller falls back to native <select>
}
$url = EDD_PLUGIN_URL;
$dir = defined( 'EDD_PLUGIN_DIR' ) ? EDD_PLUGIN_DIR : '';
$js = 'assets/vendor/js/tom-select.complete.min.js';
$css = 'assets/build/css/admin/chosen.min.css'; // host's TS skin lives here
// Guard the paths so a host restructure degrades gracefully, never fatals.
if ( $dir && ( ! file_exists( $dir . $js ) || ! file_exists( $dir . $css ) ) ) {
return false;
}
$ver = defined( 'EDD_VERSION' ) ? EDD_VERSION : MY_PLUGIN_VERSION;
wp_enqueue_script( 'my-plugin-tom-select', $url . $js, [], $ver, true );
wp_enqueue_style( 'my-plugin-tom-select', $url . $css, [], $ver );
return true;
}
// Make your own script depend on it only when present:
$dep = my_plugin_enqueue_tom_select() ? [ 'my-plugin-tom-select' ] : [];
wp_enqueue_script( 'my-plugin-admin', $assets . 'js/admin.js', $dep, MY_PLUGIN_VERSION, true );
```
Rules that make this hold up:
- **Build against the host's own constant/handle**, not a hardcoded URL into another plugin's directory. Prefer reusing a registered handle (`wp_enqueue_script('edd-tom-select')`) when the host registers it on *all* admin pages; if registration is page-scoped or order-dependent, register your own handle pointing at the bundled file (as above) for deterministic loading.
- **Always degrade.** Return a flag; init JS behind `if (typeof TomSelect !== 'undefined')`; leave the markup a real `<select>` so it works with the library absent.
- **Initialise in JS, don't fight the host's skin in markup.** For a remote/AJAX field, give the library a `load` callback hitting your `wp_ajax_*` endpoint and sync any hidden companion field (e.g. a stored label) on change.
- **Expect to override the host's styling.** The bundled skin is themed for the host. Re-skin the library's classes (`.ts-control`, `.ts-dropdown`, etc.) to your design system. WordPress admin skins carry version-gated, high-specificity selectors — EDD's `body[class*="branch-7"]` rules (WP 6.7+) out-specify a plain `.my-wrap` scope — so targeted `!important` is often required to win, and load your stylesheet after the host's.
## Notes
- When borrowing a host plugin's bundled library, pin nothing about its internal version; treat the file paths as the contract and guard them (see §8). Document the coupling in the PR so a host upgrade that moves the files is easy to trace.
- Always use `npm ci` (not `npm install`) in CI — respects `package-lock.json` exactly.
- `@wordpress/scripts` pins its webpack/babel versions; don't add conflicting `webpack` or `babel-loader` to `devDependencies`.
- For TypeScript: `@wordpress/scripts` supports `.ts`/`.tsx` out of the box — just rename files and add `tsconfig.json`.
- Minimum Node version for `@wordpress/scripts` v27+: Node 20.
- Use `wp-scripts lint-js` and `wp-scripts lint-style` in CI alongside PHPCS (`wp-coding-standards`) for full code quality coverage.Use when a PR has QA-reported failures, a "Testing Failed" label, or QA comments saying features are broken. Covers reading QA feedback, tracing root causes, applying scoped commits, updating PR labels, and posting a QA re-test comment.
# Fix PR QA Failures
## Overview
Full workflow for diagnosing and fixing bugs reported by QA on an open PR. Starts from the GitHub PR URL, ends with labels updated and a QA re-test comment posted.
## Supporting files
- `scripts/fetch-pr-context.sh <pr> [owner/repo]` — Step 1 in one command: prints
PR metadata, labels, and the full QA comment thread (newest last).
- `references/root-cause-patterns.md` — catalog of recurring bug patterns
(symptom → cause → detect → fix). **Read before tracing; append after.**
- `references/qa-comment-template.md` — the Step 8 re-test comment template + rules.
- `references/github-actions-wp-matrix.md` — PHPUnit/PHPCS/PHPStan workflow configs, PHP×WP version matrix, caching, and CI failure triage.
## Workflow
```dot
digraph fix_qa {
rankdir=TB;
"Read PR + QA comments" -> "Checkout branch";
"Checkout branch" -> "Trace root causes in code";
"Trace root causes in code" -> "Fix scope-by-scope";
"Fix scope-by-scope" -> "Lint / static analysis";
"Lint / static analysis" -> "Commit each fix separately";
"Commit each fix separately" -> "Push branch";
"Push branch" -> "Update PR labels";
"Update PR labels" -> "Post QA re-test comment";
}
```
## Step 1 — Read PR and QA comments
```bash
scripts/fetch-pr-context.sh <number> <owner/repo>
```
Or manually:
```bash
gh pr view <number> --repo <owner/repo> \
--json title,body,author,baseRefName,headRefName,state,labels
gh pr view <number> --repo <owner/repo> --json comments,reviews
```
Collect:
- Which features QA says are broken (exact words) — read the **latest** QA comment; across re-fix rounds some features get confirmed fixed while others stay broken
- Which features QA confirms work (do not regress these)
- Label currently on the PR (`Testing Failed`, `Need Testing`, etc.)
## Step 2 — Checkout the branch
```bash
git fetch origin <branch>
git checkout <branch>
```
If the repo is not cloned locally, find it under `wp-content/plugins/` or the relevant project path.
## Step 3 — Trace root causes
Start from the action/hook/controller that handles the broken feature.
**First, scan `references/root-cause-patterns.md`** — most QA failures match a
known pattern (array-cast-to-1, hook-fired-in-one-path, chart-renders-raw-id,
duplicate-component-drift, default-margin-misalignment). It gives symptom →
detect → fix for each.
Key questions when no known pattern matches:
1. What hook/action fires this email / feature? Where is it fired
(`grep -rn "do_action( 'hook'"`)? Is it reached on EVERY path to that state?
2. Are any model `get_*()` returning wrong values after `load()` mutates state?
3. (Frontend) Is a lib component rendering a raw key because no label/tooltip
render prop was passed? Does a sibling surface have a fix this one lacks?
When you trace a NEW non-obvious cause, append it to the catalog before moving on.
## Step 4 — Fix scope-by-scope
One logical bug = one commit. Do not bundle unrelated fixes.
After each fix, verify the changed files:
```bash
# PHP
vendor/bin/phpcs app/Models/ChangedFile.php
# JS/TS lint
yarn lint <files>
# Frontend changes: the build is the real proof (lint/tsc may be noisy)
yarn build # or: node_modules/.bin/wp-scripts build
```
**If the tooling env is broken** (corepack lockfile error, eslintrc circular
config, tsc halting on deprecations — all common on machines where global
toolchain versions drifted from the lockfile), fall back to the local binary:
```bash
node_modules/.bin/eslint <files>
node_modules/.bin/tsc --noEmit --ignoreDeprecations 6.0
node_modules/.bin/wp-scripts build
```
**Separate pre-existing errors from yours.** A noisy lint/tsc run (e.g. 60+
errors) is usually env/version mismatch, not your change. Confirm none of the
errors reference your changed files. Pre-existing errors in unrelated files are
OK to leave — only fix what your change introduced. The build compiling
successfully is the strongest signal a frontend fix is sound.
## Step 5 — Commit each fix
```bash
git add app/Path/To/ChangedFile.php
git commit -m "$(cat <<'EOF'
fix(scope): short imperative summary
Root cause: <what was actually wrong>
Fix: <what was changed and why>
EOF
)"
```
Use `fix(scope):` prefix. Scope = the subsystem (referral, transaction, email, spa, etc.).
**Before committing built assets:** check whether `build/` (or `dist/`) is
tracked or ignored, and match the repo's convention — don't commit generated
output if prior PR commits were source-only:
```bash
git check-ignore build/ # prints "build/" if ignored → commit source only
git show --stat <prev-commit> # confirm what prior fix commits included
```
## Step 6 — Push
```bash
git push origin <branch>
```
No new PR needed if the branch already has an open PR — the new commits update it automatically.
## Step 7 — Update labels
Only swap if the current label is wrong for re-test. If the PR is **already**
`Need Testing` (a re-fix round on a still-open testing cycle), leave it — no
change needed. Swap only when moving off a terminal state:
```bash
# See available labels first
gh label list --repo <owner/repo>
# Only if currently "Testing Failed"/"Testing Ongoing":
gh pr edit <number> --repo <owner/repo> \
--remove-label "Testing Failed" \
--add-label "Need Testing"
```
## Step 8 — Post QA re-test comment
Use `references/qa-comment-template.md`. Comment must include all four:
| Section | Content |
|---|---|
| Summary table | Each broken feature → root cause → fix (1 line each) |
| Step-by-step test instructions | Numbered steps per feature, specific UI path |
| Regression check | Ask QA to re-verify previously working items |
| Commit SHAs | Latest fix commits so QA knows what to test against |
```bash
gh pr comment <number> --repo <owner/repo> --body "$(cat <<'EOF'
<filled-in template from references/qa-comment-template.md>
EOF
)"
```
## Common Mistakes
| Mistake | Fix |
|---|---|
| Bundling multiple bug fixes in one commit | One logical fix per commit — easier to revert and for QA to trace |
| Ignoring pre-existing lint errors | Only fix errors your changes introduced |
| Opening a new PR when branch already has one | Just push; the existing PR updates automatically |
| Vague QA comment ("fixed bugs") | Name each broken feature, give exact UI steps |
| Forgetting regression check in QA comment | Always ask QA to confirm previously passing items still pass |
| Not verifying `do_action` call sites | Check ALL places a hook should fire, not just the obvious one |
| Skipping build on frontend PRs | Lint/tsc may be broken or noisy; `yarn build` compiling is the real proof |
| Treating noisy lint/tsc as your fault | 60+ errors = env/version drift. Confirm none name your files, then proceed |
| Committing gitignored `build/` output | Check `git check-ignore build/`; match prior commits (usually source-only) |
| Swapping a label that's already correct | If already `Need Testing` on a re-fix round, leave it |
| Reading only the first QA comment | Read the latest — features confirmed fixed in round 1 shouldn't be re-touched |Use when setting up PHPCS with WordPress Coding Standards (WPCS), configuring phpcs.xml.dist, running or fixing sniff violations, adding PHPCS to CI, or auditing a plugin's code style against WP.org requirements. Not for PHPStan type analysis — use wp-phpstan-stubs for that.
# WordPress Coding Standards (PHPCS + WPCS)
Configure and enforce the WordPress Coding Standards via PHP_CodeSniffer. WPCS is required for WP.org submission and is the canonical style guide for all WordPress PHP code.
## When to use
- "Set up PHPCS for my plugin", "add WordPress coding standards", "configure phpcs.xml".
- "Fix sniff violations", "run phpcbf", "auto-fix coding standards".
- "Add PHPCS to GitHub Actions / CI".
- "Why is PHPCS flagging X?", "suppress a false-positive sniff".
- Pre-submission audit: "is my code style WP.org–compliant?"
**Not for:** PHPStan static analysis or type checking — use `wp-phpstan-stubs`. Security auditing beyond style issues — use `wp-plugin-audit`.
## Method
### 1. Install
```bash
composer require --dev squizlabs/php_codesniffer wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
```
`dealerdirect/phpcodesniffer-composer-installer` auto-registers WPCS paths so no manual `--config-set` is needed. Verify:
```bash
vendor/bin/phpcs -i
# should list: WordPress, WordPress-Core, WordPress-Docs, WordPress-Extra
```
### 2. Configure `phpcs.xml.dist`
Place at project root. This is the canonical config file (`.dist` allows local `phpcs.xml` override).
```xml
<?xml version="1.0"?>
<ruleset name="My Plugin">
<description>WordPress Coding Standards for My Plugin</description>
<!-- What to scan -->
<file>.</file>
<exclude-pattern>vendor/*</exclude-pattern>
<exclude-pattern>node_modules/*</exclude-pattern>
<exclude-pattern>build/*</exclude-pattern>
<exclude-pattern>*.min.js</exclude-pattern>
<exclude-pattern>*.min.css</exclude-pattern>
<exclude-pattern>tests/bootstrap.php</exclude-pattern>
<!-- PHP version target -->
<config name="testVersion" value="7.4-"/>
<!-- Ruleset -->
<rule ref="WordPress-Extra">
<!-- Suppress if you use short array syntax (WP allows it since WP 5.5) -->
<!-- <exclude name="Generic.Arrays.DisallowShortArraySyntax"/> -->
</rule>
<rule ref="WordPress-Docs"/>
<!-- Text domain for i18n sniffs -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array" value="my-plugin"/>
</properties>
</rule>
<!-- Minimum WP version for deprecated functions -->
<rule ref="WordPress.WP.DeprecatedFunctions">
<properties>
<property name="minimum_supported_version" value="5.9"/>
</properties>
</rule>
<!-- Prefix all globals -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array" value="my_plugin,MyPlugin"/>
</properties>
</rule>
<!-- Show sniff codes in output (for targeted suppression) -->
<arg value="ps"/>
<arg name="extensions" value="php"/>
<arg name="colors"/>
</ruleset>
```
### 3. Run
```bash
# Check
vendor/bin/phpcs
# Auto-fix (safe mechanical fixes only — review after)
vendor/bin/phpcbf
# Single file or directory
vendor/bin/phpcs includes/class-my-class.php
# Show full sniff code for each violation (useful for writing suppressions)
vendor/bin/phpcs --report=full -s
```
### 4. Inline suppression
Suppress only when the sniff is a genuine false positive, not to hide real issues.
```php
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in template
echo $pre_escaped_html;
// phpcs:disable WordPress.DB.DirectDatabaseQuery
$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}my_table WHERE id = %d", $id ) );
// phpcs:enable WordPress.DB.DirectDatabaseQuery
```
Common false positives and correct suppression codes:
| Situation | Sniff to ignore |
|---|---|
| Pre-escaped variable via custom escaper | `WordPress.Security.EscapeOutput.OutputNotEscaped` |
| Intentional direct DB query with `prepare()` | `WordPress.DB.DirectDatabaseQuery.DirectQuery` |
| Custom DB cache managed explicitly | `WordPress.DB.DirectDatabaseQuery.NoCaching` |
| `__FILE__` used in `plugin_dir_url()` | `WordPress.Security.PluginMenuSlug` (rare) |
| Slow DB query that is intentional | `WordPress.DB.SlowDBQuery.slow_db_query_meta_query` |
### 5. Common sniff violations and fixes
**Missing nonce verification:**
```php
// Bad
$value = sanitize_text_field( $_POST['field'] );
// Good
if ( ! isset( $_POST['my_plugin_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['my_plugin_nonce'] ), 'my_action' ) ) {
wp_die( esc_html__( 'Security check failed.', 'my-plugin' ) );
}
$value = sanitize_text_field( wp_unslash( $_POST['field'] ) );
```
**Missing `wp_unslash()` before sanitize:**
```php
// Bad — sanitize_text_field on slashed data
$val = sanitize_text_field( $_POST['field'] );
// Good
$val = sanitize_text_field( wp_unslash( $_POST['field'] ) );
```
**Unescaped output:**
```php
echo $title; // Bad
echo esc_html( $title ); // Good
echo wp_kses_post( $html_content ); // Good for HTML
```
**Yoda conditions:**
```php
if ( $value == true ) {} // Bad
if ( true == $value ) {} // Good (Yoda)
if ( $value ) {} // Also fine
```
**Incorrect hook comment spacing:**
```php
add_action('init', 'my_fn'); // Bad — no spaces inside parens
add_action( 'init', 'my_fn' ); // Good
```
### 6. GitHub Actions CI
```yaml
# .github/workflows/phpcs.yml
name: PHPCS
on: [push, pull_request]
jobs:
phpcs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
tools: composer
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/phpcs
```
### 7. IDE integration
**PHPStorm:** Settings → PHP → Quality Tools → PHP_CodeSniffer → set path to `vendor/bin/phpcs`. Enable "Inspections → PHP → PHP Code Sniffer validation".
**VS Code:** Install `shevaua.phpcs` extension. Set `phpcs.executablePath` to `./vendor/bin/phpcs` in workspace settings.
## Notes
- `WordPress-Extra` is a superset of `WordPress-Core`; always use `WordPress-Extra` unless you have a specific reason to be less strict.
- `WordPress-Docs` is separate — it enforces PHPDoc blocks. Include it for WP.org submissions.
- WPCS sniffs for i18n (`WordPress.WP.I18n`) catch missing text domains and non-translatable strings — complement to `wp-plugin-audit` Dimension B.
- WP.org review does **not** run PHPCS automatically, but reviewers check style manually and will reject poorly formatted code. PHPCS passing is a strong signal of submission readiness.
- For WooCommerce extensions, add `WooCommerce-Core` ruleset if available (`woocommerce/woocommerce-sniffs`).Use when creating custom database tables with dbDelta, writing schema migrations and upgrade routines, querying with $wpdb prepared statements, optimising slow queries, handling custom table data with CRUD patterns, migrating data between plugin versions, or seeding sample/preview data into custom tables for local development.
# WordPress Custom Database Tables
Create and manage custom database tables in WordPress plugins: `dbDelta()` for schema definition, versioned upgrade routines, `$wpdb` CRUD with prepared statements, and data migration strategies.
## When to use
- "Create a custom DB table for my plugin", "set up plugin schema with dbDelta".
- "Write a migration for plugin upgrade", "run schema changes on update".
- "Query a custom table", "insert/update/delete with $wpdb".
- "Optimise a slow custom query", "add an index to a plugin table".
- "Migrate data from post meta to a custom table".
**Not for:** WooCommerce order table operations — use `wp-woocommerce`. General $wpdb query optimisation in core WP tables — use `wp-performance` (official skill).
## Method
### 1. Create table with dbDelta
`dbDelta()` is the only WP-safe way to create and alter tables — it diffs the current schema against the SQL and applies only the necessary changes.
```php
function my_plugin_create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate(); // e.g. DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci
// $wpdb->prefix respects multisite site prefix automatically
$table_log = $wpdb->prefix . 'my_plugin_log';
$table_meta = $wpdb->prefix . 'my_plugin_item_meta';
// IMPORTANT: two spaces before PRIMARY KEY, one space before each KEY
// IMPORTANT: no trailing comma on last field before closing paren
$sql = "CREATE TABLE {$table_log} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
item_id bigint(20) unsigned NOT NULL,
action varchar(100) NOT NULL DEFAULT '',
message longtext NOT NULL,
user_id bigint(20) unsigned NOT NULL DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY item_id (item_id),
KEY created_at (created_at)
) {$charset_collate};
CREATE TABLE {$table_meta} (
meta_id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
item_id bigint(20) unsigned NOT NULL,
meta_key varchar(255) NOT NULL DEFAULT '',
meta_value longtext,
PRIMARY KEY (meta_id),
KEY item_id (item_id),
KEY meta_key (meta_key(191))
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
```
**Critical dbDelta formatting rules** (violations cause silent failures):
- Two spaces after `PRIMARY KEY` (e.g. `PRIMARY KEY (id)`)
- Field definitions must end with a comma except the last field before the closing paren
- `KEY` lines go after all field definitions, before the closing paren
- Only `CREATE TABLE` statements — no `ALTER TABLE` (dbDelta handles column additions, not removals)
- Always include `{$charset_collate}` at the end
### 2. Versioned upgrade routine
Track schema version in an option; only re-run dbDelta when the version changes:
```php
define( 'MY_PLUGIN_DB_VERSION', '1.3.0' );
function my_plugin_maybe_upgrade_db() {
$installed = get_option( 'my_plugin_db_version', '0' );
if ( version_compare( $installed, MY_PLUGIN_DB_VERSION, '>=' ) ) {
return; // already up to date
}
my_plugin_create_tables(); // always safe to re-run dbDelta
// Version-specific data migrations
if ( version_compare( $installed, '1.2.0', '<' ) ) {
my_plugin_migrate_to_1_2_0();
}
if ( version_compare( $installed, '1.3.0', '<' ) ) {
my_plugin_migrate_to_1_3_0();
}
update_option( 'my_plugin_db_version', MY_PLUGIN_DB_VERSION );
}
add_action( 'plugins_loaded', 'my_plugin_maybe_upgrade_db' );
```
Run on `plugins_loaded` (every request until updated), not just on activation — catches updates after auto-update or version switch.
### 3. $wpdb CRUD
Always use `$wpdb->prepare()` for any value from user input or untrusted source.
**Insert:**
```php
$result = $wpdb->insert(
$wpdb->prefix . 'my_plugin_log',
[
'item_id' => $item_id,
'action' => 'view',
'message' => $message,
'user_id' => get_current_user_id(),
'created_at' => current_time( 'mysql' ),
],
[ '%d', '%s', '%s', '%d', '%s' ] // format for each value: %d int, %s string, %f float
);
$inserted_id = $wpdb->insert_id;
```
**Update:**
```php
$wpdb->update(
$wpdb->prefix . 'my_plugin_log',
[ 'message' => $new_message ], // data
[ 'id' => $log_id ], // where
[ '%s' ], // data format
[ '%d' ] // where format
);
```
**Delete:**
```php
$wpdb->delete(
$wpdb->prefix . 'my_plugin_log',
[ 'item_id' => $item_id ],
[ '%d' ]
);
```
**Select — single row:**
```php
$row = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_log WHERE id = %d",
$log_id
)
); // returns stdClass or null
```
**Select — multiple rows:**
```php
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}my_plugin_log WHERE item_id = %d ORDER BY created_at DESC LIMIT %d",
$item_id,
50
)
); // returns array of stdClass
```
**Select — single value:**
```php
$count = (int) $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}my_plugin_log WHERE action = %s",
'view'
)
);
```
**Raw query (DDL / no-result):**
```php
// phpcs:ignore WordPress.DB.DirectDatabaseQuery
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->prefix}my_plugin_log WHERE created_at < %s",
gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) )
)
);
```
### 4. Error handling
```php
$result = $wpdb->insert( ... );
if ( false === $result ) {
// $wpdb->last_error contains the MySQL error
error_log( 'my-plugin DB error: ' . $wpdb->last_error );
return new WP_Error( 'db_insert_error', $wpdb->last_error );
}
```
Enable query logging during development:
```php
define( 'SAVEQUERIES', true );
// Then: print_r( $wpdb->queries )
```
### 5. Schema migrations (data migrations)
For migrating existing data (not just schema changes):
```php
function my_plugin_migrate_to_1_2_0() {
global $wpdb;
// Example: move post meta to custom table
$meta_rows = $wpdb->get_results(
"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = '_my_plugin_data'"
);
if ( ! $meta_rows ) return;
$table = $wpdb->prefix . 'my_plugin_log';
foreach ( $meta_rows as $row ) {
$wpdb->insert( $table, [
'item_id' => $row->post_id,
'message' => $row->meta_value,
'action' => 'migrated',
], [ '%d', '%s', '%s' ] );
}
// Remove the old meta after successful migration
$wpdb->delete( $wpdb->postmeta, [ 'meta_key' => '_my_plugin_data' ], [ '%s' ] );
}
```
For large datasets, use batches (via `wp-background-processing`):
```php
function my_plugin_migrate_batch( $offset = 0 ) {
global $wpdb;
$batch = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->postmeta} WHERE meta_key = '_old_key' LIMIT 100 OFFSET %d",
$offset
) );
// ... process batch ...
if ( count( $batch ) === 100 ) {
// More to process — schedule next batch
as_enqueue_async_action( 'my_plugin_migrate_batch', [ 'offset' => $offset + 100 ], 'my-plugin' );
} else {
update_option( 'my_plugin_migration_complete', true );
}
}
```
### 6. Table removal on uninstall
Use `register_uninstall_hook` (not `deactivation_hook`) for destructive cleanup:
```php
// uninstall.php (registered via register_uninstall_hook(__FILE__, ...) or placed at plugin root)
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) exit;
global $wpdb;
// Drop per-site tables on multisite
if ( is_multisite() ) {
$sites = get_sites( [ 'number' => 0, 'fields' => 'ids' ] );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_log" );
delete_option( 'my_plugin_db_version' );
restore_current_blog();
}
} else {
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}my_plugin_log" );
delete_option( 'my_plugin_db_version' );
}
```
### 7. Seeding sample / preview data (dev only)
To populate custom tables with realistic data for local preview, write a standalone script run via WP-CLI's `wp eval-file` — never auto-loaded by the plugin. Keep it in a `tools/` dir and document it in `tools/README.md`.
```php
<?php
// tools/seed-dev-data.php — run: wp eval-file tools/seed-dev-data.php [--fresh]
if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { exit( "Run via: wp eval-file <this file>\n" ); }
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_log';
// --fresh truncates first. TRUNCATE is destructive — gate it, and expect the
// agent permission classifier to block it unless tables are already empty.
if ( in_array( '--fresh', (array) ( $args ?? [] ), true ) ) {
$wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:ignore
}
foreach ( $rows as $row ) {
$wpdb->insert( $table, $row ); // hardcoded columns only
}
WP_CLI::success( 'Seeded.' );
```
Conventions that keep seeders safe and re-runnable:
- **Idempotent where it matters.** A seeder that creates linked records (WP users, EDD payments) should skip rows already linked — e.g. `if ( ! empty( $row->payment_id ) ) continue;` — so reruns don't duplicate. A pure log-filler can be additive; say so in the script header and accept a count arg (`(int) ( $args[0] ?? 0 ) ?: 20`).
- **Link to real WP objects, not fakes.** Create real users with `wp_insert_user()` and reuse by email (`get_user_by`); mint EDD orders through the plugin's own purchase wrapper (e.g. an `EDD` integration class) rather than raw inserts, so the seeded data exercises the real code path. Write the resulting `user_id` / `payment_id` back onto the custom-table row.
- **Generate via WP-CLI, verify via `wp db query`.** Confirm row counts/links after seeding.
- **Dev-only.** Never ship `tools/`; never run against production. Use `current_time('mysql')` / `gmdate()` for timestamps, and seed `extra`/JSON columns with `wp_json_encode()`.
Note on randomness: scripts run by `wp eval-file` may warn on large int math (`$x * 2654435761` overflows to float) — keep PRNG seeds inside `& 0x7fffffff`.
## Notes
- `dbDelta()` can ADD columns but cannot remove them. Removing columns requires `ALTER TABLE DROP COLUMN` in a manual migration step.
- Never use `$wpdb->prepare()` with `%s` for integers — always `%d`. Using `%s` on an integer is not a security issue but causes type coercion surprises.
- Column names in `$wpdb->insert()` / `update()` / `delete()` are NOT escaped — they must be hardcoded, never user-supplied.
- Cache expensive custom queries: `$results = wp_cache_get( $cache_key, 'my_plugin' ); if ( false === $results ) { $results = $wpdb->get_results(...); wp_cache_set( $cache_key, $results, 'my_plugin', 300 ); }`
- Always include `bigint(20) unsigned NOT NULL AUTO_INCREMENT` as the primary key — matches WP core table conventions.
- Use `gmdate()` not `date()` for DB timestamps; WP's `current_time('mysql')` returns local time — use it only when you need WP's configured timezone.Use when adding or refactoring transactional emails in a WordPress plugin — extract inline email strings into reusable HTML templates that share one branded base wrapper, sent as HTML via wp_mail().
# Reusable HTML Email Templates (WordPress plugin)
Move email bodies out of inline PHP strings into `templates/emails/`, where every message reuses one branded shell. Adding a new email = adding one content file.
## When to use
- "Move email content to templates", "branded/HTML emails", "reuse an email template".
- Any plugin building messages inline with `wp_mail()` heredocs.
## Structure
```
templates/emails/
base.php shared shell: header, body slot ($content), footer
<name>.php per-email content (greeting, copy, CTA button)
```
- **base.php** — table-based, inline-CSS responsive shell (email clients ignore `<style>`/external CSS). Receives `$subject`, `$preheader`, `$content`, `$site_name`, `$site_url`. Echoes `$content` raw (`// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped` — content templates escape their own values).
- **content templates** — escape every variable (`esc_html`, `esc_url`); wrap copy in `__()`/`esc_html__()` with the text domain; use `_n()` for plurals. Guard each `$var = $var ?? default;` so the file is robust if rendered standalone.
## Render helpers (in your Helpers class)
```php
public static function render_template( string $path, array $vars = [] ): string {
if ( ! is_readable( $path ) ) return '';
// phpcs:ignore WordPress.PHP.DontExtract.extract_extract -- controlled template vars
extract( $vars, EXTR_SKIP );
ob_start();
include $path;
return (string) ob_get_clean();
}
public static function render_email( string $template, array $vars = [] ): string {
$dir = (string) plugin_config( 'templates' ) . 'emails/'; // your assets/templates path helper
$vars['content'] = self::render_template( $dir . $template . '.php', $vars );
$vars['site_name'] = $vars['site_name'] ?? get_bloginfo( 'name' );
$vars['site_url'] = $vars['site_url'] ?? home_url( '/' );
return self::render_template( $dir . 'base.php', $vars ); // always wrap in the shell
}
```
A missing content template yields an empty body but the shell still renders — callers always get a valid document.
## Sending
```php
$body = Helpers::render_email( 'welcome', [ 'subject' => $subject, 'user_name' => $u->display_name, ... ] );
$headers = [ 'Content-Type: text/html; charset=UTF-8' ]; // REQUIRED for HTML
return wp_mail( $to, $subject, $body, $headers );
```
Drive any TTL/expiry copy from the same constant the code uses (e.g. a token transient TTL) so email text can't drift from behavior.
## Gotchas
- Entities in translated strings: `©` double-escapes through `esc_html__` → use the literal `©`.
- Don't assign WordPress globals in templates (`$year` etc. trip `WordPress.WP.GlobalVariablesOverride`) — inline `gmdate('Y')` instead.
## Testing
WP's test suite captures `wp_mail` via MockPHPMailer:
```php
reset_phpmailer_instance();
// ... trigger the send ...
$sent = tests_retrieve_phpmailer_instance()->get_sent();
$this->assertStringContainsString( 'text/html', $sent->header );
$this->assertStringContainsString( '<!DOCTYPE html', $sent->body ); // base shell used
$this->assertStringContainsString( $expected_cta, $sent->body );
```
Force a send failure with `add_filter( 'pre_wp_mail', '__return_false' )` to test the error branch.
## References
- `references/base.php` — the full responsive HTML base shell (header/body/footer, inline CSS), copy into `templates/emails/base.php`.
- `references/content-example.php` — an example content template (greeting, CTA button, optional expiry, fallback link) to copy and adapt per email.Use when integrating the Freemius SDK into a WordPress plugin for monetisation — free/pro feature gating, license management, trials, SaaS pricing plans, SDK bootstrap setup, update mechanism, opt-in analytics, or affiliate program integration.
# Freemius SDK Integration
Integrate Freemius into a WordPress plugin for commercial distribution: SDK bootstrap, free/pro feature gating, license management, trials, pricing page, and the Freemius dashboard. Freemius handles payments, license keys, update delivery, and analytics.
## When to use
- "Add Freemius to my plugin", "set up free/pro version", "implement license management".
- "Gate premium features behind a license", "add a trial period".
- "Create a pricing page", "set up a Freemius affiliate program".
- "Debug Freemius SDK not loading", "fix opt-in dialog not showing".
- "Configure Freemius for multisite licensing".
**Not for:** General WooCommerce payment flows — use `wp-woocommerce`. WP.org trialware compliance (Freemius-powered upsells must follow WP.org Guideline 5 — use `wp-org-submission`, which contains `references/trialware-compliance.md`).
## Method
### 1. Create a Freemius account and app
1. Sign up at `https://freemius.com`
2. Create a new **Plugin** product in the Freemius dashboard
3. Note down: **Plugin ID**, **Public Key**, **Secret Key**
4. Configure pricing plans (Free, Pro, etc.) in the dashboard
### 2. Install the SDK
**Via Composer (recommended):**
```bash
composer require freemius/wordpress-sdk
```
**Manual:** Download from `https://github.com/Freemius/wordpress-sdk` and place in `vendor/freemius/`.
### 3. Bootstrap the SDK
Create `includes/freemius.php` (the Freemius singleton init file):
```php
<?php
if ( ! function_exists( 'my_plugin_fs' ) ) {
function my_plugin_fs() {
global $my_plugin_fs;
if ( ! isset( $my_plugin_fs ) ) {
// Include the Freemius SDK
require_once plugin_dir_path( __FILE__ ) . '../vendor/freemius/wordpress-sdk/start.php';
$my_plugin_fs = fs_dynamic_init( [
'id' => '12345', // Plugin ID from dashboard
'slug' => 'my-plugin', // WP.org slug
'type' => 'plugin',
'public_key' => 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // Public key
'is_premium' => false, // true if this IS the premium build
'has_premium_version' => true, // true if premium version exists
'has_addons' => false,
'has_paid_plans' => true,
'trial' => [
'days' => 14,
'is_require_payment' => false, // true = credit card required
],
'menu' => [
'slug' => 'my-plugin',
'contact' => false,
'support' => false,
],
] );
}
return $my_plugin_fs;
}
// Init Freemius
my_plugin_fs();
// Hook Freemius after initial plugin setup
do_action( 'my_plugin_fs_loaded' );
}
```
**Load from main plugin file:**
```php
// At the top of my-plugin.php, before any premium-gated code
require_once plugin_dir_path( __FILE__ ) . 'includes/freemius.php';
```
### 4. Feature gating
Gate premium features consistently throughout the codebase:
```php
// Check if user has an active paid plan (or trial)
if ( my_plugin_fs()->can_use_premium_code() ) {
// Show/run premium feature
require_once plugin_dir_path( __FILE__ ) . 'includes/class-premium-feature.php';
}
// Check if the premium code file is loaded (for the __premium_only__ pattern)
if ( my_plugin_fs()->is__premium_only() ) {
// Always true when premium file is present
}
// Specific plan check
if ( my_plugin_fs()->is_plan( 'professional', true ) ) {
// $true = or_greater: matches 'professional' and any higher plan
}
// Trial check
if ( my_plugin_fs()->is_trial() ) {
echo 'Trial active: ' . my_plugin_fs()->get_trial_plan()->title;
}
// Free user upsell prompt
if ( ! my_plugin_fs()->is_paying() ) {
// Show upgrade CTA
$upgrade_url = my_plugin_fs()->get_upgrade_url();
}
```
**`__premium_only__` file pattern** — Freemius strips these files from the free build:
```
my-plugin/
├── includes/
│ ├── class-core.php # Free + premium
│ └── premium/ # Only in premium zip
│ └── class-advanced.php__premium_only__
```
### 5. Pricing page
Freemius generates a hosted pricing page. Embed in the plugin's admin:
```php
add_action( 'my_plugin_fs_loaded', function() {
my_plugin_fs()->add_submenu_link_item(
__( 'Upgrade', 'my-plugin' ),
my_plugin_fs()->get_upgrade_url(),
'upgrade',
'manage_options',
51,
'dashicons-star-filled',
true // is_external: opens pricing in new tab
);
} );
// Or embed the pricing page directly inside WP admin
function my_plugin_pricing_page() {
echo my_plugin_fs()->get_pricing_js_tag( true );
}
```
### 6. Opt-in analytics
Freemius prompts users to opt into anonymous data collection on activation. Customise the dialog:
```php
$my_plugin_fs = fs_dynamic_init( [
// ...
'opt_in' => [
'type' => 'dialog', // 'dialog', 'inline', or 'none'
'is_enabled' => true,
'anonymous_mode_enabled' => true, // allow "skip" without opting in
],
'is_org_compliant' => true, // WP.org: must allow "skip"
] );
// Skip opt-in programmatically (for white-label / privacy-first)
add_action( 'my_plugin_fs_loaded', function() {
my_plugin_fs()->skip_connection();
} );
```
### 7. Multisite licensing
Configure in `fs_dynamic_init()`:
```php
'is_premium' => false,
'has_premium_version' => true,
'license_key_grace_period' => 7, // days after expiry before locking
'bundle_id' => null, // set if part of a bundle
'network_key_type' => 'per-site', // 'per-site', 'per-domain', 'unlimited'
```
Options:
- `per-site` — each subsite needs its own license activation
- `per-domain` — one license covers all subsites on the same domain
- `unlimited` — one license covers all
### 8. Freemius dashboard integration
**Key dashboard pages to configure:**
- **Plans** — name, price, features per plan, billing cycle (monthly/annual)
- **Pricing** — set currency, pricing page URL
- **Affiliates** — enable affiliate program, commission rate
- **Licenses** — activation limits per license key
- **Updates** — premium zip is auto-served via Freemius CDN on license activation
**SDK update delivery** — authenticated users receive updates via the Freemius API (not WP.org). The SDK hooks into WP's update mechanism automatically:
```php
// No extra code needed — Freemius handles wp_update_plugins transparently
```
### 9. Common SDK issues
**Opt-in dialog not showing:**
- Check `'opt_in' => [ 'type' => 'dialog' ]` in init config
- Verify user has `manage_options` capability
- Clear `fs_accounts` option: `delete_option( 'fs_accounts' )`
**"Invalid API Secret Key" error:**
- Never expose secret key in client-side JS or public code
- Rotate key in Freemius dashboard if exposed
**Premium features visible without license:**
- Ensure `can_use_premium_code()` wraps ALL premium code paths
- Check that the free zip doesn't include `__premium_only__` files
**SDK conflicts with other Freemius plugins:**
- Freemius uses a global `$fs_active_plugins` object. Conflicts resolved by SDK auto-loader — ensure only one copy of the SDK is loaded (use `if ( ! function_exists( 'fs_dynamic_init' ) )`).
## Notes
- Freemius `is_org_compliant` must be `true` for WP.org–hosted plugins. Without it, the opt-in dialog blocks deactivation — a violation of WP.org guidelines.
- The free version (on WP.org) and premium version (via Freemius) are separate zips. Build both from the same codebase using the `__premium_only__` suffix.
- Never gate plugin activation behind a license key (Guideline 5 / trialware). Freemius `can_use_premium_code()` returns `false` but the plugin must remain fully functional in free mode.
- Secret key must never be committed to git. Store in a CI secret or server env var; inject at build time.
- For WooCommerce extensions sold on WooCommerce.com, consider WooCommerce's own licensing API instead of Freemius.Use when shipping a contribution through GitHub — either (a) debugging a GitHub issue by URL/number (fetch, root-cause, fix), or (b) shipping uncommitted working-tree changes when the user says "commit my changes", "commit scope by scope", "create a branch and PR", "open a PR for these changes", or similar. Covers grouping changes into scoped conventional commits, creating a branch, pushing, and opening a PR with assignee + labels. Use this whenever the end goal is a commit/branch/PR, even if no issue is mentioned.
# GitHub Contribution Flow
## Overview
Two entry modes that converge on the same shipping flow (branch → scoped commits → push → PR):
- **Issue-driven** — fetch issue → trace root cause → fix → ship. Wraps `superpowers:systematic-debugging`. Root cause MUST be confirmed before any fix is written.
- **Changes-driven** — read the working tree → group changes by conventional-commit scope → one commit per scope → ship. No issue required.
Both end at **§6 Branch, Commit, PR**, which is shared.
## When to use
**Issue-driven:**
- User says "analyze/debug/fix/investigate issue #NNN"
- User pastes a GitHub issue URL and asks to debug it
- User says "find the root cause of this bug" with an issue reference
**Changes-driven:**
- "commit my changes", "commit these scope by scope", "commit by scope"
- "create a branch and PR", "open a PR for these changes"
- "read all changes from X and commit + create PR"
- Any request whose end goal is commits/branch/PR from existing working-tree edits
## Required Sub-Skill
**Issue-driven only:** Invoke `superpowers:systematic-debugging` before any fix. Do NOT skip Phase 1 (root cause investigation). Changes-driven mode skips this — the edits already exist.
## References
- `references/gh-reference.md` — `gh` CLI commands, branch naming rules, label discovery, PR template sections, common CI failures
- `references/project-entry-points.md` — AcmeBlocks free/pro layout, repos, registry + render paths, project conventions
- `references/conventional-commits.md` — type/scope table, WP-specific scope list, summary rules, multi-commit PR rules, footer conventions, rebase reword
## Repo ≠ where the issue lives
The repo that **hosts the issue** is often NOT the repo that **holds the code / receives the PR**. Resolve two names up front and keep them distinct:
- `ISSUE_REPO` — where `gh issue view` / `gh issue edit` run.
- `CODE_REPO` — where the affected code lives; where you branch, push, and `gh pr create --repo "$CODE_REPO"`.
When they differ, the PR's close footer must be **cross-repo**: `Closes ISSUE_OWNER/ISSUE_REPO#N` (a bare `Closes #N` only closes an issue in the same repo).
Real example (this org): issues live in `acme-org/acme-blocks`, but the buggy widget code + PR live in `acme-org/acme-blocks-pro` → PR opens on `acme-blocks-pro`, body says `Closes acme-org/acme-blocks#247`.
---
## Changes-Driven Workflow
Use this when the user wants to ship existing uncommitted edits. Skip to §6's mechanics but commit **scope by scope** rather than one lump.
### A. Read every change
```bash
git status
git diff # unstaged
git diff --staged # staged
```
Read the **full diff**, not just the file list — a single file can contain edits belonging to different scopes, and the commit message's "why" depends on what actually changed.
### B. Group changes by scope
Map each change to one conventional-commit scope. **Scopes are project-defined — read the repo's CLAUDE.md for the list, don't assume.**
- ShopFlow: `api, cart, checkout, orders, products, customers, payments, shipping, taxes, coupons, inventory, admin, dashboard, blocks, spa, database, templates, email, reports, build, i18n`.
- AcmeBlocks: per-widget/module scope, e.g. `product-barcode, recently-viewed-products, product-countdown, shop, library, widgets, modules, build, i18n` (see `references/project-entry-points.md`).
Group by **what the change is about**, not just which directory it lives in. Examples from real sessions:
- `pages/Coupons/index.jsx` + `pages/Coupons/CouponList/index.jsx` → both `coupons`
- `pages/AbandonedCart/index.jsx`, `pages/Transactions/...` → `admin`
- `.yarnrc.yml`, `package.json` → `build`
One scope can span multiple files; one file *can* split across commits via `git add -p` if it genuinely mixes concerns (rare — prefer not to).
**Exception — one cohesive cross-cutting task = one commit.** Scope-by-scope is the default, but when the change is a *single logical task* that inherently touches many files across categories (e.g. fixing all findings from an audit, a mechanical rename, a dir restructure), a single commit with an **enumerated body** is cleaner and more honest than forcing fragile per-file `git add -p` splits. Judge by intent: distinct concerns → separate commits; one purpose expressed across many files → one commit.
### C. Branch first, then commit each scope
Create the branch (§6.1), then make **one commit per scope** so each is independently reviewable and revertable:
```bash
git add <files-for-scope-1>
git commit -m "fix(coupons): <imperative summary>"
git add <files-for-scope-2>
git commit -m "fix(admin): <imperative summary>"
# ...repeat per scope
```
Use the correct `<type>` per scope (`fix`, `feat`, `build`, `refactor`, …) — don't blanket-`fix` everything. Use `caveman:caveman-commit` for terse, exact messages.
### D. Push + PR
Push and open the PR via §6 — but the PR body summarizes **all** scopes, and there is no `Closes #N` unless an issue was given. PR title uses the dominant scope or a neutral summary across scopes.
> **Heads-up:** only commit what the user asked for. If `git status` shows unrelated files (e.g. a stray `package.json` from another task), call them out and leave them unstaged rather than sweeping them in.
---
## Issue-Driven Workflow
### 1. Detect Canonical Repo(s)
Before anything else, resolve the true repo(s) — remotes go stale when orgs rename or transfer, and the issue repo may differ from the code repo (see "Repo ≠ where the issue lives").
```bash
# What git thinks origin is, vs what GitHub actually says (follows redirects)
git remote get-url origin
gh repo view --json nameWithOwner -q .nameWithOwner
# If they differ — update origin
git remote set-url origin $(gh repo view --json cloneUrl -q .cloneUrl)
```
Store BOTH names — they may be the same, but never assume it:
```bash
ISSUE_REPO=acme-org/acme-blocks # where the issue is tracked
CODE_REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner) # where you'll PR
```
### 2. Fetch Issue
```bash
gh issue view <number> --repo "$ISSUE_REPO" --comments
```
Extract: title, description, screenshots, reproduction steps, error messages, **and the comment thread** (assignee, "need to update API", "issue not found" all live there). For a parent/tracking issue, also fetch each sub-issue + its comments.
### 3. Locate Affected Code
- `grep -r` for identifiers, class names, function names from the issue
- Trace from UI/endpoint inward to data origin
- Check recent commits: `git log --oneline -- <file>`
- **Find the code's repo.** It may not be the issue repo, and not even the same plugin (free vs pro). `git -C <plugin-dir> remote get-url origin` to confirm which GitHub repo each path maps to → that's `CODE_REPO`.
For AcmeBlocks — see `references/project-entry-points.md`.
### 4. Root Cause Investigation
Follow `superpowers:systematic-debugging` Phase 1 fully:
1. Read error messages / inspect screenshots carefully
2. Reproduce data flow mentally (or add logging)
3. Form ONE explicit hypothesis: "Root cause is X because Y"
4. Confirm hypothesis before writing any fix
**No fixes until root cause is stated and confirmed.**
### 5. Fix
One minimal change. No cleanup, no refactoring, no "while I'm here" edits.
### 6. Branch, Commit, PR *(shared by both modes)*
```bash
# Confirm base branch with user if unclear — never assume main/develop
BASE_BRANCH=<ask-user>
```
**6.1 — Create branch from the FRESH base.** Local branches go stale; always branch from `origin/<base>` after fetching, so the PR diff is clean.
```bash
git fetch origin "$BASE_BRANCH"
git checkout -b <branch> "origin/$BASE_BRANCH"
```
Name it by mode:
- Issue-driven: `bugfix/<issue-number>-<short-description>`
- Changes-driven: `<type>/<short-description>` (e.g. `fix/container-height-full`)
**Cluster (multiple sub-issues):** do NOT lump fixes for several issues into one branch. One branch + one PR **per issue**, each cut fresh from `origin/$BASE_BRANCH`, each `Closes` its own issue. Fix file A on its branch → push → PR → `git checkout "$BASE_BRANCH"` → next. Independent files = no conflicts.
**6.2 — Commit.** Use `caveman:caveman-commit` for messages.
- *Issue-driven:* one minimal commit, with `Closes #<issue-number>` footer.
- *Changes-driven:* one commit **per scope** (see Changes-Driven §C). No `Closes` unless an issue was given.
```bash
git add <files-for-scope>
git commit -m "fix(<scope>): <imperative summary>" # +blank line + why + Closes when issue-driven
```
Follow project conventions in the repo's CLAUDE.md. For AcmeBlocks: **never introduce deprecated `ab_*` aliases — use the canonical `acme_blocks_*` functions** (and convert any deprecated call inside a hunk you're already editing). See `references/project-entry-points.md`.
**6.3 — Push (must happen before the PR).** `gh pr create` fails with *"must first push the current branch"* if you skip this.
```bash
git push -u origin <branch>
```
**6.4 — Discover labels, THEN create the PR.** Label names are per-repo; `gh pr create` **aborts the whole command** if any `--label` doesn't exist (e.g. `needs testing` vs `needs-testing`). List them first and map to what's real:
```bash
gh label list --repo "$CODE_REPO"
```
Then create — always pass `--repo "$CODE_REPO"` (gh context can differ from git remote), `--assignee @me`, and only labels that exist. Common mapping: a "ready for QA" label (`needs-testing` / `needs testing`) + a type/area label (`bug`, `frontend`, `backend`). Cross-repo close footer when `ISSUE_REPO ≠ CODE_REPO`.
```bash
gh pr create \
--repo "$CODE_REPO" \
--base "$BASE_BRANCH" \
--assignee @me \
--label "<verified-qa-label>" \
--label "<verified-area-label>" \
--title "<type>(<scope>): <summary>" \
--body "$(cat <<'EOF'
## 📝 Summary
<what this PR does — cover all scopes if multi-commit>
---
## 🛠️ Related Issues / Tickets
Closes <ISSUE_OWNER>/<ISSUE_REPO>#<issue-number> <!-- bare "Closes #N" only if same repo; omit section if no issue -->
---
## 📦 Type of Change
- [x] 🐛 Bug fix <!-- adjust to feat/refactor/build as appropriate -->
---
## 🔍 Changes Made
- <bullet per scope: what changed and why>
---
## 🧪 Test Instructions
1. <step>
2. <step>
---
## ✅ Checklist
- [x] My code follows the project's coding style and conventions
- [x] This PR is scoped, focused, and doesn't mix unrelated changes
EOF
)"
```
> **Verification honesty:** if an automated test isn't feasible (e.g. Elementor edit-mode render needs a bootstrap the suite lacks), say so plainly in the checklist and rely on `php -l` + manual steps — don't claim a test you didn't write.
**6.5 — Merge PR.** When the user asks to merge a PR, always use a **regular merge commit** (`--merge`). Never squash (`--squash`) — it collapses the branch's individual commits into one, destroying the per-scope history that §6.2 deliberately preserved. Never rebase (`--rebase`) unless the user explicitly requests it.
```bash
gh pr merge <number> --merge --delete-branch
```
`--delete-branch` is safe and keeps the remote tidy. Do not add `--squash` or `--rebase`.
**6.6 — After merge: verify, then re-sync before the next branch.** A merge isn't done until it's confirmed landed and local `main` is caught up — otherwise the next branch is cut from a stale base.
```bash
gh pr view <number> --json state,mergedAt -q '{state: .state, mergedAt: .mergedAt}' # expect MERGED
git checkout "$BASE_BRANCH"
git pull origin "$BASE_BRANCH"
git rev-parse "$BASE_BRANCH"; git rev-parse "origin/$BASE_BRANCH" # must match
```
## Sequential PRs in one session
When an open PR already exists and the user asks for the **next** piece of work, do NOT stack the new changes on the unmerged branch. The user's preferred flow:
1. **Merge the open PR first** (`gh pr merge <n> --merge --delete-branch`).
2. **`git checkout "$BASE_BRANCH"` + `git pull`** so the base is current.
3. **Branch fresh** off the updated base for the new work.
If the new work genuinely depends on the unmerged branch and can't wait, branch off *that* branch and rebase onto the base once it merges (the cluster pattern) — but the default is merge-first.
## Keep an open PR's body in sync
If you push **more commits** to a branch whose PR is already open (scope grew mid-review), update the PR title/body so the description still matches the diff:
```bash
gh pr edit <number> --title "<updated>" --body "$(cat <<'EOF'
...all scopes now in the PR...
EOF
)"
```
A PR body that lists only half the commits reads as "the rest snuck in."
---
## Quick Reference
| Step | Tool |
|------|------|
| Detect code repo | `gh repo view --json nameWithOwner` / `git -C <dir> remote get-url origin` |
| Fix stale remote | `git remote set-url origin <new-url>` |
| Fetch issue + comments *(issue mode)* | `gh issue view <n> --repo "$ISSUE_REPO" --comments` |
| Read changes *(changes mode)* | `git status`, `git diff`, `git diff --staged` |
| Find code | `grep -r`, `find`, file reads |
| Branch from fresh base | `git fetch origin "$BASE_BRANCH" && git checkout -b <b> "origin/$BASE_BRANCH"` |
| Commit message | `caveman:caveman-commit` |
| Discover labels (before PR) | `gh label list --repo "$CODE_REPO"` |
| Push (before PR) | `git push -u origin <branch>` |
| Create PR | `gh pr create --repo "$CODE_REPO" --base "$BASE_BRANCH" --assignee @me --label "<verified>"` |
| Merge PR | `gh pr merge <number> --merge --delete-branch` — **never `--squash` or `--rebase`** |
| Verify merge + re-sync | `gh pr view <n> --json state,mergedAt` then `git checkout <base> && git pull` (rev-parse local == origin) |
| Update open PR after new commits | `gh pr edit <number> --title … --body …` |
---
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Hardcoded repo name | Always derive from `gh repo view --json nameWithOwner` |
| Assuming issue repo == code repo | Resolve `ISSUE_REPO` + `CODE_REPO` separately; PR on `CODE_REPO`; cross-repo `Closes owner/repo#N` |
| Stale `origin` remote | Run remote detect step (§1) before every push |
| Branching off a stale local base | `git fetch origin <base>` then branch from `origin/<base>` |
| `gh pr create` without `--repo` | `gh` context can differ from `git` remote — always pass `--repo "$CODE_REPO"` |
| PR command before pushing branch | Push first — `gh pr create` errors "must first push the current branch" |
| Hardcoded label aborts PR | `gh label list` first; one unknown `--label` fails the whole `gh pr create` |
| Lumping multiple issues in one branch | One branch + PR per issue, each from fresh `origin/<base>` |
| Claiming an automated test you didn't write | If a test isn't feasible, say so; rely on lint + manual steps, stated plainly |
| Introducing deprecated `ab_*` (AcmeBlocks) | Use canonical `acme_blocks_*`; convert deprecated calls inside hunks you touch |
| One lumped commit for mixed changes | Changes mode: split by scope, one commit each |
| Reading only the file list | Read full `git diff` — a file can mix scopes, and the "why" needs the actual change |
| Sweeping in unrelated files | Only stage what the user asked for; flag stray changes, leave them unstaged |
| Blanket `fix:` on everything | Pick the right `<type>` per scope (`feat`/`build`/`refactor`/…) |
| `Closes #N` with no issue | Omit the issue section entirely in changes mode |
| Fix without root cause *(issue mode)* | State hypothesis explicitly before coding |
| Fixing symptom not origin *(issue mode)* | Trace full data flow to find where bad value is born |
| Wrong base branch | Ask user; never assume `develop` or `main` |
| Missing assignee | Always pass `--assignee @me` |
| Missing QA label | Add the repo's "ready for QA" label (verify exact name via `gh label list`) |
| Missing area label | Add the repo's area/type label (`bug`/`frontend`/`backend`/…) — whatever exists |
| Bundled refactoring in fix | One change only — keep diff minimal |
| Squash merge | **Never** use `--squash` — destroys per-scope commit history. Use `--merge` always. |
| Rebase merge without being asked | Never use `--rebase` unless user explicitly requests it — rewrites SHAs, breaks bisect. |
| Stacking next task on an unmerged PR branch | Merge the open PR first, `git pull` the base, then branch fresh (Sequential PRs). |
| Branching after merge without re-pulling | A merge updates the remote base, not your local — `git checkout <base> && git pull` before the next branch. |
| Open PR body stale after new commits | `gh pr edit` the body to cover every commit now on the branch. |
| Forcing per-file splits on one cohesive task | An audit cleanup / rename / restructure is one commit with an enumerated body — not fragile `git add -p`. |Use when implementing a guided tour system in a WordPress admin plugin using Driver.js — setting up the IIFE bundle, defining PHP backend tour configs, writing JS scope detection from URL + hash, tracking completion correctly, testing selectors against live DOM, and regenerating the POT file.
# WordPress Admin Guided Tours (Driver.js)
## Setup
### 1 — Vendor Driver.js
Download the Driver.js v1 IIFE build (NOT the ESM build):
- `driver.js.iife.js` → `assets/admin/js/driverjs/driver.js.iife.js`
- `driver.css` → `assets/admin/js/driverjs/driver.css`
The IIFE build exposes `window.driver.js.driver` (double namespace). Always call it as:
```js
window.driver.js.driver({ ... })
```
### 2 — Enqueue in Asset.php
```php
// Enqueue on all admin pages (is_admin() block)
$this->enqueue_script(
'shopflow_guided_tour_driverjs',
SHOPFLOW_ASSETS_URL . 'admin/js/driverjs/driver.js.iife.js',
array()
);
$this->enqueue_style(
'shopflow_guided_tour_driverjs',
SHOPFLOW_ASSETS_URL . 'admin/js/driverjs/driver.css',
array()
);
$this->enqueue_script(
'shopflow_guided_tour',
SHOPFLOW_ASSETS_URL . 'admin/js/guided-tour.js',
array( 'shopflow_guided_tour_driverjs' )
);
// Add tour configs to the main localized object
$localized['tours'] = shopflow_get_tour_configs();
```
The `$localized` array must be passed to `localize_script()` on the **main SPA script** (not the tour script) so `window.SHOPFLOW.tours` is available before `guided-tour.js` runs.
---
## PHP Tour Config (`functions.php`)
```php
function shopflow_get_tour_configs() {
return apply_filters( 'shopflow_tour_configs', array(
'dashboard' => array(
'autoStart' => true, // only one scope should be true
'pages' => array( 'shopflow' ),
'steps' => array(
array(
// Centered popover — no element key
'popover' => array(
'title' => __( 'Welcome!', 'my-plugin' ),
'description' => __( 'Quick intro text.', 'my-plugin' ),
'side' => 'bottom',
),
),
array(
// Element-targeted step
'element' => '#my-stable-id',
'popover' => array(
'title' => __( 'Step Title', 'my-plugin' ),
'description' => __( 'Step description.', 'my-plugin' ),
'side' => 'right',
),
),
),
),
) );
}
```
**Rules:**
- Only one scope should have `autoStart: true` (the primary onboarding page)
- Always use `__()` on title and description — run `makepot` after adding new steps
- `side` values: `top`, `bottom`, `left`, `right`
- Do NOT set `align: 'start'` — it's the default; explicit is noise
- `pages` array is metadata only; actual detection is done by JS `getCurrentScope()`
---
## JS Scope Detection (`guided-tour.js`)
```js
function getCurrentScope() {
const urlParams = new URLSearchParams(window.location.search);
const page = urlParams.get('page');
if (!page || !page.startsWith('myprefix')) return null;
// Non-SPA pages (full page reloads)
if (page === 'myprefix-settings') return 'settings';
if (page === 'myprefix-wizard') return 'wizard';
// Main SPA — differentiate by hash route
// Strip pagination suffix like /page/2
const hash = window.location.hash.replace('#', '').replace(/\/page\/\d+$/, '');
if (!hash || hash === '/' || hash === '/dashboard') return 'dashboard';
if (hash.startsWith('/products/add')) return 'add-product';
if (hash === '/orders/new') return 'create-order';
if (hash === '/orders') return 'orders';
if (hash === '/customers') return 'customers';
if (hash.startsWith('/reports')) return 'reports';
return null;
}
```
**Key points:**
- Check `page.startsWith('myprefix')` — NOT `page.startsWith('myprefix-')` (would miss the bare slug `page=myprefix`)
- Hash routes need explicit prefix matching (`.startsWith`) for pages with sub-routes
- `hashchange` listener re-runs scope detection for SPA navigation:
```js
window.addEventListener('hashchange', () => setTimeout(autoStartTours, 500));
```
---
## JS Tour Lifecycle
```js
let currentTour = null;
function startTour(scope = null) {
if (!window.driver?.js?.driver) {
console.warn('Driver.js not loaded');
return false;
}
const targetScope = scope || getCurrentScope();
if (!targetScope || !window.SHOPFLOW?.tours[targetScope]) return false;
if (currentTour) currentTour.destroy();
const steps = window.SHOPFLOW.tours[targetScope].steps;
const lastIndex = steps.length - 1;
// Inject completion tracking ONLY on the final step's Next/Done click.
// onDestroyed fires for BOTH completion AND early dismiss — do NOT use it
// for completion tracking.
const stepsWithCompletion = steps.map((step, i) => {
if (i !== lastIndex) return step;
return {
...step,
popover: {
...step.popover,
onNextClick: () => {
markTourCompleted(targetScope);
currentTour.destroy();
},
},
};
});
currentTour = window.driver.js.driver({
showProgress: true,
smoothScroll: true,
showButtons: ['next', 'previous', 'close'],
steps: stepsWithCompletion,
onDestroyed: () => { currentTour = null; },
});
setTimeout(() => currentTour.drive(), 100);
return true;
}
```
**Critical:** `onDestroyed` fires on close AND completion. Never call `markTourCompleted` there. Inject it into the last step's `onNextClick` only.
---
## Selector Rules
| Pattern | Good/Bad | Reason |
|---------|----------|--------|
| `#my-stable-id` | ✅ | Most stable |
| `.unique-class-combo` | ✅ | Stable if combo is unique |
| `.my-class:first-of-type` | ❌ | `:first-of-type` matches by tag, not class |
| `:nth-child(2)` | ❌ | Breaks on DOM reorder |
| Tailwind responsive variants | ⚠️ | Need backslash escaping in PHP strings |
**Tailwind escaping in PHP:**
```php
// CSS selector: .border-[#F0EDFB]
// In PHP string:
'element' => '.border-\\[\\#F0EDFB\\]',
```
**Test every selector in browser console before committing:**
```js
!!document.querySelector('.my-selector') // must return true on target page
```
**Important:** Test each selector on its OWN page. A selector for the orders tour will return `false` on the dashboard — that's expected.
---
## Browser Verification Checklist
After implementing tours, verify in browser:
```js
// 1. All tours loaded
Object.keys(window.SHOPFLOW.tours) // should list all scopes
// 2. Scope detection works on current page
getCurrentScope() // should return expected scope string
// 3. All selectors resolve (run on EACH tour's own page)
window.SHOPFLOW.tours['my-scope'].steps
.filter(s => s.element)
.map(s => ({ el: s.element, found: !!document.querySelector(s.element) }))
// 4. Tour renders
localStorage.clear()
startTour('my-scope')
// 5. Completion tracking — click Done on last step
localStorage.getItem('myprefix_my-scope_tour_completed') // → "true"
// 6. Dismiss tracking — restart, click X on step 1
// localStorage key must NOT be set
```
---
## Post-Implementation Checklist
- [ ] Run `composer run makepot` — all `__()` strings in tour configs must be in `.pot`
- [ ] Every scope in `shopflow_get_tour_configs()` has a matching case in `getCurrentScope()`
- [ ] Only one scope has `autoStart: true`
- [ ] All element selectors verified on their own pages via browser console
- [ ] Completion fires on Done, not on X/close
- [ ] `smoothScroll: true` in driver config (prevents jarring jumps on long pages)
- [ ] No `align: 'start'` in steps (redundant default)
- [ ] Update docs file if one existsUse when managing the full translation workflow for a WordPress plugin — generating POT files with wp i18n make-pot, compiling .po to .mo and .json, setting up JavaScript translations with wp_set_script_translations, submitting to translate.wordpress.org, or debugging missing translations.
# WordPress Plugin i18n Workflow
Full translation pipeline for WordPress plugins: POT generation, PO/MO compilation, JavaScript translations, translate.wordpress.org GlotPress, and language pack distribution. Covers the coding conventions and the tooling workflow.
## When to use
- "Generate a POT file for my plugin", "update translation strings".
- "Set up JavaScript translations", "translate strings in React/block editor".
- "Submit to translate.wordpress.org", "set up language packs".
- "Why aren't my translations loading?", "debug missing .mo file".
- "Add translator comments", "handle plurals and context strings".
**Not for:** PHPCS i18n sniff violations — use `wp-coding-standards`. Checking i18n completeness in an audit — use `wp-plugin-audit` Dimension B.
## Method
### 1. PHP i18n conventions
All translatable strings must use the plugin's **text domain** consistently. The text domain must match the `Text Domain:` header and the `load_plugin_textdomain()` call.
```php
// Basic translation
__( 'Settings', 'my-plugin' )
_e( 'Save Changes', 'my-plugin' ) // echo version
// With HTML context (escape + translate combined)
esc_html__( 'Error message', 'my-plugin' )
esc_attr__( 'Tooltip text', 'my-plugin' )
// Plurals
_n( '%d item', '%d items', $count, 'my-plugin' )
sprintf( _n( '%d item', '%d items', $count, 'my-plugin' ), $count )
// Context strings (disambiguation for translators)
_x( 'Post', 'noun: a blog post', 'my-plugin' )
_ex( 'Draft', 'verb: save as draft', 'my-plugin' )
// Plural with context
_nx( '%d reply', '%d replies', $count, 'comment count', 'my-plugin' )
```
**Translator comments** — required for strings with placeholders:
```php
/* translators: %s: plugin version number */
sprintf( __( 'Version %s', 'my-plugin' ), MY_PLUGIN_VERSION )
/* translators: 1: post title, 2: author name */
sprintf( __( '"%1$s" by %2$s', 'my-plugin' ), $title, $author )
```
Comment must be on the line immediately before the function call and start with `translators:`.
### 2. Load text domain
```php
add_action( 'init', function() {
load_plugin_textdomain(
'my-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages/'
);
} );
```
For WP.org plugins, language packs are auto-loaded from `translate.wordpress.org` — `load_plugin_textdomain()` only needed for bundled `.mo` files or local development.
### 3. Generate POT file
```bash
# WP-CLI (preferred)
wp i18n make-pot . languages/my-plugin.pot \
--domain=my-plugin \
--exclude=vendor,node_modules,tests,build \
--headers='{"Project-Id-Version":"My Plugin 1.0.0","Report-Msgid-Bugs-To":"https://github.com/my-org/my-plugin/issues"}'
# Update existing POT (merges new strings, marks removed as obsolete)
wp i18n make-pot . languages/my-plugin.pot --domain=my-plugin
```
Commit `languages/my-plugin.pot` to git. Translators use this as the source.
### 4. Compile PO → MO
`.po` files are human-editable; `.mo` are compiled binary files loaded by PHP.
```bash
# Single file
wp i18n make-mo languages/my-plugin-fr_FR.po
# All PO files in the directory
wp i18n make-mo languages/
# Using msgfmt (gettext tools)
msgfmt languages/my-plugin-fr_FR.po -o languages/my-plugin-fr_FR.mo
```
File naming convention: `{text-domain}-{locale}.po` / `.mo`
Examples: `my-plugin-fr_FR.mo`, `my-plugin-de_DE.mo`, `my-plugin-pt_BR.mo`
### 5. JavaScript translations
**Block editor / React components** — use `@wordpress/i18n`:
```js
import { __, _n, _x, sprintf } from '@wordpress/i18n';
const label = __( 'Save settings', 'my-plugin' );
const count = sprintf( _n( '%d item', '%d items', total, 'my-plugin' ), total );
const ctx = _x( 'Draft', 'button label', 'my-plugin' );
```
**Generate JSON translation files:**
```bash
# From PO file — produces my-plugin-fr_FR-{hash}.json
wp i18n make-json languages/my-plugin-fr_FR.po --no-purge
```
**Register JS translations in PHP:**
```php
function my_plugin_set_script_translations() {
wp_set_script_translations(
'my-plugin-editor', // script handle (must be enqueued)
'my-plugin', // text domain
plugin_dir_path( __FILE__ ) . 'languages'
);
}
add_action( 'init', 'my_plugin_set_script_translations' );
```
For blocks registered via `block.json`, WP auto-calls `wp_set_script_translations` if `textdomain` is set in `block.json`:
```json
{
"textdomain": "my-plugin",
"editorScript": "file:./index.js"
}
```
### 6. translate.wordpress.org (GlotPress)
WP.org plugins get a GlotPress project automatically once approved. Language packs are built weekly and distributed via the WP update system.
**Setup steps:**
1. Plugin approved on WP.org → GlotPress project auto-created at `translate.wordpress.org/projects/wp-plugins/your-slug/`
2. Ensure `languages/` dir exists in SVN trunk with the `.pot` file
3. GlotPress imports strings from trunk automatically (or trigger via SVN commit)
4. Community translators contribute at `translate.wordpress.org`
5. At 95% translation completion, a language pack is created and distributed to users
**WP.org translation validator:** `https://i18n.svn.wordpress.org/`
**Correct POT headers for GlotPress:**
```
Project-Id-Version: My Plugin 1.0.0
Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/my-plugin
Last-Translator: FULL NAME <EMAIL@ADDRESS>
Language-Team: LANGUAGE <LL@li.org>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
```
### 7. Debugging missing translations
**Checklist:**
```bash
# 1. Verify text domain matches header and load_plugin_textdomain()
grep -r "Text Domain:" *.php
grep -r "load_plugin_textdomain" includes/
# 2. Verify .mo file exists and locale matches WP locale
wp option get WPLANG # should match e.g. fr_FR
ls languages/ # should have my-plugin-fr_FR.mo
# 3. Verify .mo can be loaded
wp eval "echo load_plugin_textdomain('my-plugin', false, 'path/to/languages/');"
# 4. Verify string is in POT
grep -A2 "your string" languages/my-plugin.pot
# 5. For JS translations — check JSON files exist
ls languages/*.json
# 6. Check wp_set_script_translations fires after script is enqueued
# (Must call AFTER wp_enqueue_script, usually on 'init' or 'enqueue_scripts')
```
**Common failure modes:**
| Symptom | Cause | Fix |
|---|---|---|
| Strings show in English only | `.mo` missing or wrong locale | Run `wp i18n make-mo languages/` |
| JS strings not translated | JSON file missing or wrong handle | Run `wp i18n make-json`, verify handle |
| POT out of date | New strings not extracted | Re-run `wp i18n make-pot` |
| Translator comment not picked up | Not on immediately preceding line | Move comment to line above call |
| WP.org language pack not appearing | < 95% translated | Complete translations on translate.wordpress.org |
### 8. Automation — sync POT on release
Add to GitHub Actions or release workflow:
```yaml
- name: Generate POT
run: wp i18n make-pot . languages/my-plugin.pot --domain=my-plugin --exclude=vendor,node_modules,build
- name: Compile MO files
run: wp i18n make-mo languages/
- name: Generate JS JSON
run: wp i18n make-json languages/ --no-purge
```
## Notes
- Never concatenate translatable strings: `__( 'Hello' ) . ' ' . __( 'World' )` — translators can't reorder. Use `sprintf( __( 'Hello %s', 'my-plugin' ), $name )`.
- Never use variables as the first argument: `__( $dynamic_string, 'my-plugin' )` — POT extractors can't find these strings.
- RTL languages (Arabic, Hebrew, Farsi): WordPress detects RTL from the locale and loads `rtl.css` automatically. Mirror your `style.css` in `style-rtl.css` for layout flips.
- `wp i18n` commands require WP-CLI 2.2+. In CI, install via `curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar`.Use when building or adapting a WordPress plugin for multisite/network — network activation, network admin pages, per-site vs network options, switch_to_blog() patterns, super admin capabilities, table prefix handling, get_sites() loops, or site-aware hook registration.
# WordPress Multisite Plugin Development
Adapt and build plugins that work correctly on WordPress multisite networks. Covers activation scope, option storage, network admin UI, capability model, and safe site-switching patterns.
## When to use
- "Make my plugin multisite compatible", "support network activation".
- "Add a network admin settings page", "store a network-wide option".
- "Loop over all sites and do X", "run a task on every blog".
- "Why does my plugin break on multisite?", "fix table prefix issues".
- "Check if a user is super admin", "restrict to network admin only".
**Not for:** WP-CLI multisite operations — use `wp-wpcli-and-ops` (official skill). General plugin architecture — use `wp-plugin-development`.
## Method
### 1. Detection and guarding
```php
// Is this a multisite network?
if ( is_multisite() ) { ... }
// Is the current screen the network admin?
if ( is_network_admin() ) { ... }
// Is the plugin network-activated?
if ( is_plugin_active_for_network( plugin_basename( __FILE__ ) ) ) { ... }
// Is the user a super admin?
if ( current_user_can( 'manage_network' ) ) { ... } // preferred
if ( is_super_admin() ) { ... } // also fine
```
Never assume `is_multisite()` is false — always write code that handles both cases unless the plugin explicitly requires multisite.
### 2. Activation scope
A plugin can be:
- **Site-activated** — active on one site, hooks run only on that site.
- **Network-activated** — active on all sites, activation hook runs once on the network.
```php
register_activation_hook( __FILE__, 'my_plugin_activate' );
function my_plugin_activate( $network_wide ) {
if ( $network_wide && is_multisite() ) {
// Run setup for every existing site
$sites = get_sites( [ 'number' => 0, 'fields' => 'ids' ] );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
my_plugin_setup_site();
restore_current_blog();
}
} else {
my_plugin_setup_site();
}
}
// Also run setup when a new site is created (for network-activated plugins)
add_action( 'wp_initialize_site', function( WP_Site $new_site ) {
if ( is_plugin_active_for_network( plugin_basename( __FILE__ ) ) ) {
switch_to_blog( $new_site->blog_id );
my_plugin_setup_site();
restore_current_blog();
}
} );
```
### 3. Option storage: site vs network
| Function | Scope | Storage |
|---|---|---|
| `get_option()` / `update_option()` | Current site | `{prefix}options` (per site) |
| `get_network_option()` / `update_network_option()` | Entire network | `{main_prefix}sitemeta` |
| `get_site_meta()` / `update_site_meta()` | Per-site record | `{main_prefix}blogmeta` |
```php
// Network-wide setting (same value for all sites)
$api_key = get_network_option( null, 'my_plugin_api_key' );
update_network_option( null, 'my_plugin_api_key', sanitize_text_field( $key ) );
// Per-site setting (different value per site)
$setting = get_option( 'my_plugin_site_setting', 'default' );
update_option( 'my_plugin_site_setting', $value );
// Per-site metadata on the site object
$site_note = get_site_meta( get_current_blog_id(), 'my_plugin_note', true );
update_site_meta( get_current_blog_id(), 'my_plugin_note', sanitize_textarea_field( $note ) );
```
### 4. Network admin settings page
```php
// Register under network admin menu
add_action( 'network_admin_menu', function() {
add_menu_page(
__( 'My Plugin Network', 'my-plugin' ),
__( 'My Plugin', 'my-plugin' ),
'manage_network', // super admin only
'my-plugin-network',
'my_plugin_render_network_page'
);
} );
// Network admin settings must use wp_redirect — Settings API not available in network admin
add_action( 'network_admin_edit_my_plugin_network_settings', function() {
check_admin_referer( 'my_plugin_network_settings' );
if ( ! current_user_can( 'manage_network' ) ) wp_die( -1 );
update_network_option( null, 'my_plugin_api_key',
sanitize_text_field( wp_unslash( $_POST['api_key'] ?? '' ) )
);
wp_redirect( add_query_arg( [ 'updated' => 'true' ], network_admin_url( 'settings.php?page=my-plugin-network' ) ) );
exit;
} );
function my_plugin_render_network_page() {
if ( isset( $_GET['updated'] ) ) {
echo '<div class="notice notice-success"><p>' . esc_html__( 'Settings saved.', 'my-plugin' ) . '</p></div>';
}
$api_key = get_network_option( null, 'my_plugin_api_key', '' );
?>
<div class="wrap">
<h1><?php esc_html_e( 'My Plugin Network Settings', 'my-plugin' ); ?></h1>
<form method="post" action="<?php echo esc_url( network_admin_url( 'edit.php?action=my_plugin_network_settings' ) ); ?>">
<?php wp_nonce_field( 'my_plugin_network_settings' ); ?>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'API Key', 'my-plugin' ); ?></th>
<td><input type="text" name="api_key" value="<?php echo esc_attr( $api_key ); ?>" class="regular-text" /></td>
</tr>
</table>
<?php submit_button( __( 'Save Settings', 'my-plugin' ) ); ?>
</form>
</div>
<?php
}
```
### 5. Looping over sites
```php
function my_plugin_run_for_all_sites( callable $callback ) {
if ( ! is_multisite() ) {
$callback();
return;
}
$sites = get_sites( [
'number' => 0, // no limit — consider chunking for large networks
'fields' => 'ids',
'archived' => 0,
'deleted' => 0,
'spam' => 0,
] );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
try {
$callback( $site_id );
} finally {
restore_current_blog(); // always restore, even on exception
}
}
}
// Usage
my_plugin_run_for_all_sites( function( $site_id ) {
update_option( 'my_plugin_version', MY_PLUGIN_VERSION );
} );
```
**Chunked loop for large networks:**
```php
$page = 1;
$limit = 100;
do {
$sites = get_sites( [ 'number' => $limit, 'offset' => ( $page - 1 ) * $limit, 'fields' => 'ids' ] );
foreach ( $sites as $site_id ) {
switch_to_blog( $site_id );
my_plugin_process_site();
restore_current_blog();
}
$page++;
} while ( count( $sites ) === $limit );
```
### 6. Custom DB tables on multisite
Each site has its own table prefix. Create per-site tables in the setup function (called per-site in activation):
```php
function my_plugin_setup_site() {
global $wpdb;
$table_name = $wpdb->prefix . 'my_plugin_data'; // $wpdb->prefix is site-specific
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
id bigint(20) NOT NULL AUTO_INCREMENT,
data longtext NOT NULL,
PRIMARY KEY (id)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
}
```
For **network-wide** tables (one table shared by all sites), use `$wpdb->base_prefix`:
```php
$network_table = $wpdb->base_prefix . 'my_plugin_network_log';
```
### 7. Capabilities on multisite
```php
// Super admin (network administrator) check
if ( current_user_can( 'manage_network' ) ) { ... }
// Site admin on the CURRENT site
if ( current_user_can( 'manage_options' ) ) { ... }
// Grant a cap only to super admins (can't remove built-in super admin caps)
add_filter( 'user_has_cap', function( $caps, $cap_to_check, $args, $user ) {
if ( 'manage_network_plugins' === $cap_to_check && ! is_super_admin( $user->ID ) ) {
$caps['manage_network_plugins'] = false;
}
return $caps;
}, 10, 4 );
```
## Notes
- `switch_to_blog()` is expensive — it changes `$wpdb->prefix`, flushes object cache per-site, and swaps several globals. Minimise calls; batch work per site.
- Always wrap `switch_to_blog()` / `restore_current_blog()` in `try/finally` to guarantee restoration even on errors.
- Avoid `BLOG_ID_CURRENT_SITE` constant — use `get_current_blog_id()` and `get_main_site_id()` instead.
- Network admin pages can't use the WordPress Settings API (`register_setting`, `add_settings_section`) — handle saves via `network_admin_edit_{action}` hooks with manual `wp_redirect`.
- On large networks (1000+ sites), avoid `get_sites( [ 'number' => 0 ] )` — chunk with `number`/`offset` or use Action Scheduler (`wp-background-processing`) to process sites asynchronously.
## References
- `references/multisite-patterns.md` — detection helpers, site switching, options API, get_sites() params, table prefix, capabilities, activation hooks.
- `references/network-admin-patterns.md` — network admin menu, network settings save via network_admin_edit_{action}, network option storage, network-wide transients.
- `references/multisite-testing.md` — PHPUnit multisite bootstrap, data-isolation tests, network-activation tests, new-site hook tests, gotchas.Use when submitting a plugin to the WordPress.org plugin directory for the first time, deploying a new version via SVN, fixing a reviewer rejection, or setting up WP.org assets (banner, icon, screenshots). Covers the pre-submission checklist, 17 recurring rejection patterns with exact reviewer quotes, readme.txt requirements, the git→SVN deploy flow, and how the Stable tag controls what users receive. For pure WP.org guideline compliance review (18 official guidelines, GPL, naming, trialware rules), use the official wp-plugin-directory-guidelines skill instead.
# WordPress.org Plugin Submission & SVN Deploy
Get a plugin into the WP.org directory and keep releasing to it. Two distinct phases — know which one applies:
- **Phase 1 — Initial submission.** Plugin not yet in the directory. One-time human review, then SVN access is granted.
- **Phase 2 — SVN deploy.** Plugin already approved. Ship a new version into the existing SVN repo.
`wp-org-submission` is about the *directory/SVN side*. Sync the version sources first with [[wp-plugin-release]] — this skill assumes the codebase already carries the target version.
## When to use
- "Submit this plugin to WordPress.org", "publish to the .org directory", "add my plugin to wp.org".
- "Deploy the new version to SVN", "push the release to wp.org", "tag a release on plugins.svn".
- "Set up screenshots / banner / icon", "why aren't my assets showing".
- "Fix a WP.org rejection", "respond to plugin review email", "they flagged trialware / source code / external service".
- Pre-submission hygiene sweep using the 17-issue rejection catalog (real reviewer quotes).
**Not for:** "will this pass WP.org review?", "check guideline violations", "is my plugin GPL-compliant?" — use the official `wp-plugin-directory-guidelines` skill for authoritative 18-guideline compliance review. This skill owns the *workflow* (SVN, assets, rejections); that skill owns the *rules*.
## Phase 1 — Initial submission
The review is done by humans and can take days to weeks. Submitting a clean plugin avoids round-trips.
1. **Slug availability** — the directory slug is derived from the plugin name in the main file header. Pick a name not already taken at `https://wordpress.org/plugins/<slug>/` (404 = free). Slug is permanent.
2. **readme.txt valid** — must parse in the official validator: `https://wordpress.org/plugins/developers/readme-validator/`. Required header fields, valid `Stable tag`, GPL-compatible `License`. See `references/submission-checklist.md`.
3. **Guidelines compliance** — sanitize input, escape output, nonce-protect actions, prefix all globals, no obfuscation/minified-only code, no external loading of scripts, no tracking or calling home without explicit opt-in consent, GPL-compatible code + assets only. Full checklist in `references/submission-checklist.md`. 17-issue catalog with exact reviewer quotes in `references/review-issues-catalog.md`.
4. **Build a clean zip** — source files (`src/`, `composer.json`, build configs) must be included; `.wordpress-org/` and `node_modules` must not. Full include/exclude lists in §4 of `references/submission-checklist.md`.
5. **Submit** at `https://wordpress.org/plugins/developers/add/`. The reviewer replies by email. Fix what they flag, reply briefly (context only, no change list), attach the updated zip. On approval, SVN access is granted at `https://plugins.svn.wordpress.org/<slug>/`.
## Phase 2 — SVN deploy
WP.org distributes via **Subversion**, not git. The SVN repo has three top-level dirs:
```
<slug>/
├── trunk/ # current development copy of the plugin
├── tags/ # one immutable dir per released version (tags/1.2.0/)
└── assets/ # directory listing images — NOT shipped in the plugin zip
```
**The `Stable tag` in `trunk/readme.txt` decides what users download** — it must name a directory under `tags/`. Set `Stable tag: 1.2.0` and ensure `tags/1.2.0/` exists. (Pointing Stable tag at `trunk` is legal but discouraged — always release from a tag.)
Deploy = copy the production build into `trunk/`, then `svn cp trunk tags/<version>`, then commit. Use the helper:
```bash
scripts/svn-deploy.sh <slug> <path-to-built-plugin-dir> <version>
```
It checks out SVN, syncs `trunk/` to the build (adding/removing files), copies `trunk` → `tags/<version>`, and prints the `svn commit` to run after review. Full manual walkthrough and the add/delete handling in `references/svn-deploy.md`.
**Assets** (banner, icon, screenshots) live only in `assets/`, never in the zip. Exact filenames and dimensions are mandatory — `banner-772x250.png`, `banner-1544x500.png` (retina), `icon-128x128.png`, `icon-256x256.png`, `icon.svg`, `screenshot-1.png` (matched to the `1.` line under `== Screenshots ==` in readme.txt). See `references/svn-deploy.md`.
## Top rejection patterns
Most frequent — not exhaustive. Full 17-issue catalog with exact reviewer quotes in `references/review-issues-catalog.md`.
- Generic or trademarked slug; "WordPress"/"Woo" in the plugin name.
- Main PHP file name doesn't match the slug (`plugin.php` instead of `<slug>.php`).
- Invalid URLs in plugin header or readme.txt — verify with `curl -I` before submission.
- External service called but not documented in `== External services ==`.
- Unsanitized `$_GET`/`$_POST`, unescaped output, missing nonces + capability checks.
- Loading JS/CSS from a CDN instead of bundling; inline `<style>` / `<script>` tags.
- No source for compiled output — `src/` not in zip and no public repo linked.
- `.wordpress-org/` or `node_modules/` in the zip.
- Generic function/class prefix or mixed prefixes across one plugin.
- **Trialware (Guideline 5)** — features gated behind license key or Pro plan check. See `references/trialware-compliance.md`.
- Admin notices on every screen, persistent nags, full-page upsell flows (Guideline 11).
- Stable tag names a tag that doesn't exist under `tags/` → users get nothing.
## References
- `references/submission-checklist.md` — comprehensive pre-submission checklist (identity, readme.txt, guidelines, zip hygiene, security pattern, automated checks).
- `references/review-issues-catalog.md` — 17-issue catalog with exact reviewer quotes and corrective actions, sourced from 8 real submissions.
- `references/trialware-compliance.md` — Guideline 5 freemium pattern, audit checklist, and step-by-step licensing-layer removal.
- `references/svn-deploy.md` — complete SVN workflow, asset spec, Stable-tag mechanics, hotfix flow.
- `scripts/svn-deploy.sh` — git/build → SVN trunk+tag deploy helper.
- **Official:** `wp-plugin-directory-guidelines` (WordPress/agent-skills) — authoritative source for the 18 WP.org Plugin Directory guidelines; use it for GPL/naming/trialware rule interpretation when our 17-issue catalog conflicts or is ambiguous.Use when asked to create a new PHPStan stubs package (phrases like "create stubs for X", "new stubs package", "scaffold phpstan stubs", "add stubs for plugin/composer package"). Scaffolds the full standard structure matching the my-org freemius pattern. NOT for configuring phpstan.neon or generating baselines — use the official wp-phpstan skill for that.
# PHPStan Stubs Scaffold
Scaffold a complete PHPStan stubs package from scratch, following the `my-org/phpstan-freemius-stubs` standard structure.
## References
- `references/wp-org-api.md` — WP.org plugin API, version listing, download URLs, cleanup patterns
- `references/packagist-api.md` — Packagist API, version filtering, source/composer.json update pattern
- `references/common-errors.md` — Known errors and fixes (jq `\d`, unzip prompt, find+set-e, git reset, missing source/composer.json)
- `references/github-setup.md` — Repo creation, branch rename trunk→main, topics, secrets, all 11 current repos
## Gather Required Info
Before writing any files, collect (ask user if missing):
1. **Plugin/package name** — human-readable (e.g. "SureCart", "Action Scheduler")
2. **Source type** — one of:
- `wp-plugin` — downloadable from WordPress.org (slug known)
- `composer` — Composer package on Packagist (e.g. `woocommerce/action-scheduler`)
- `paid` — paid plugin, user places source manually
3. **WP.org slug** (if `wp-plugin`) — e.g. `surecart`, `forminator`
4. **Packagist package** (if `composer`) — e.g. `woocommerce/action-scheduler`
5. **Source dir path inside package** — where the plugin/package files land:
- `wp-plugin`: `source/<slug>/`
- `composer`: `source/vendor/<vendor>/<package>/`
- `paid`: `source/<slug>/`
6. **Packagist name** — `my-org/phpstan-<slug>-stubs`
7. **GitHub repo name** — `phpstan-<slug>-stubs`
8. **Versions to release** — for `wp-plugin`/`composer`: minor version series (e.g. `3.4 3.5 3.6`); for `paid`: manual
9. **GitHub assignee** — default `my-org`
## Standard Directory Layout
```
phpstan-<slug>-stubs/
├── bin/
│ ├── generate.sh
│ └── release-latest-versions.sh # (not for paid)
├── configs/
│ ├── bootstrap.php
│ └── finder.php
├── source/
│ ├── composer.json # only if wp-plugin or paid (no composer deps)
│ └── .gitignore
├── .github/
│ └── workflows/
│ └── release.yml # (not for paid)
├── .editorconfig
├── .gitattributes
├── .gitignore
├── composer.json
├── phpstan.neon
└── <slug>-stubs.php # empty placeholder (generated)
<slug>-constants-stubs.php # empty placeholder (generated)
```
## File Contents
### `composer.json`
```json
{
"name": "my-org/phpstan-<slug>-stubs",
"description": "<PluginName> function and class declaration stubs for static analysis.",
"type": "library",
"keywords": [
"<slug>",
"wordpress",
"static analysis",
"phpstan",
"stubs"
],
"homepage": "https://github.com/my-org/phpstan-<slug>-stubs",
"license": "MIT",
"authors": [
{
"name": "Al Amin Ahamed",
"homepage": "https://github.com/my-org"
}
],
"require": {
"php": ">=7.4",
"php-stubs/wordpress-stubs": "^5.3 || ^6.0"
},
"require-dev": {
"php-stubs/generator": "^0.8.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
"squizlabs/php_codesniffer": "^3.7"
},
"minimum-stability": "stable",
"prefer-stable": true,
"config": {
"allow-plugins": {
"php-stubs/generator": true
},
"sort-packages": true,
"optimize-autoloader": true,
"preferred-install": "dist",
"platform": {
"php": "7.4.0"
}
},
"scripts": {
"post-install-cmd": [
"@composer --working-dir=source/ update --no-interaction"
],
"post-update-cmd": [
"@composer --working-dir=source/ update --no-interaction"
],
"generate": "bash bin/generate.sh",
"release": "bash bin/release-latest-versions.sh"
},
"support": {
"issues": "https://github.com/my-org/phpstan-<slug>-stubs/issues",
"source": "https://github.com/my-org/phpstan-<slug>-stubs"
}
}
```
**For `composer` source type** — `source/` has its own composer.json, so omit `post-install-cmd`/`post-update-cmd` from root composer.json and add them pointing to the source subdir instead. The source/composer.json requires the actual package:
```json
{
"require": {
"php": ">=5.6",
"<vendor>/<package>": "<latest-stable-version>"
},
"minimum-stability": "stable"
}
```
**For `wp-plugin` and `paid`** — create `source/composer.json`:
```json
{"minimum-stability": "stable"}
```
And `source/.gitignore`:
```
/vendor/
/composer.lock
```
### `configs/bootstrap.php`
Standard WordPress constants file — copy verbatim from freemius, then append plugin-specific constants at bottom if needed:
```php
<?php
declare(strict_types=1);
// phpcs:disable Squiz.PHP.DiscouragedFunctions,NeutronStandard.Constants.DisallowDefine
define('ABSPATH', './');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
define('WP_DEBUG_DISPLAY', true);
define('WP_PLUGIN_DIR', './');
define('WPMU_PLUGIN_DIR', './');
define('EMPTY_TRASH_DAYS', 30 * 86400);
define('SCRIPT_DEBUG', false);
define('WP_LANG_DIR', './');
define('WP_CONTENT_DIR', './');
define('MINUTE_IN_SECONDS', 60);
define('HOUR_IN_SECONDS', 60 * MINUTE_IN_SECONDS);
define('DAY_IN_SECONDS', 24 * HOUR_IN_SECONDS);
define('WEEK_IN_SECONDS', 7 * DAY_IN_SECONDS);
define('MONTH_IN_SECONDS', 30 * DAY_IN_SECONDS);
define('YEAR_IN_SECONDS', 365 * DAY_IN_SECONDS);
define('KB_IN_BYTES', 1024);
define('MB_IN_BYTES', 1024 * KB_IN_BYTES);
define('GB_IN_BYTES', 1024 * MB_IN_BYTES);
define('TB_IN_BYTES', 1024 * GB_IN_BYTES);
define('OBJECT', 'OBJECT');
define('OBJECT_K', 'OBJECT_K');
define('ARRAY_A', 'ARRAY_A');
define('ARRAY_N', 'ARRAY_N');
define('FS_CONNECT_TIMEOUT', 30);
define('FS_TIMEOUT', 30);
define('FS_CHMOD_DIR', 0755);
define('FS_CHMOD_FILE', 0644);
```
### `configs/finder.php`
```php
<?php
use StubsGenerator\Finder;
return Finder::create()
->in(array(
'<source-dir-path>',
))
->sortByName(true)
;
```
Replace `<source-dir-path>` with the actual path (relative to project root):
- `wp-plugin`: `source/<slug>`
- `composer`: `source/vendor/<vendor>/<package>`
- `paid`: `source/<slug>`
Add `->notPath(...)` calls to exclude test dirs, docs, or large asset dirs that bloat the stubs.
### `bin/generate.sh`
```bash
#!/usr/bin/env bash
#
# Generate <PluginName> stubs from the source directory.
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
HEADER=$'/**\n * Generated stub declarations for <PluginName>.\n * @see <homepage-url>\n * @see https://github.com/my-org/phpstan-<slug>-stubs\n */'
FILE="$ROOT_DIR/<slug>-stubs.php"
FILE_CONSTANTS="$ROOT_DIR/<slug>-constants-stubs.php"
GENERATOR_BIN="$ROOT_DIR/vendor/bin/generate-stubs"
FINDER_FILE="$ROOT_DIR/configs/finder.php"
set -e
test -f "$FILE" || touch "$FILE"
test -f "$FILE_CONSTANTS" || touch "$FILE_CONSTANTS"
test -d "$ROOT_DIR/<source-dir-path>"
"$GENERATOR_BIN" \
--include-inaccessible-class-nodes \
--force \
--finder="$FINDER_FILE" \
--header="$HEADER" \
--functions \
--classes \
--interfaces \
--traits \
--out="$FILE"
"$GENERATOR_BIN" \
--include-inaccessible-class-nodes \
--force \
--finder="$FINDER_FILE" \
--header="$HEADER" \
--constants \
--out="$FILE_CONSTANTS"
```
### `bin/release-latest-versions.sh` — WP.org plugin
Iterates minor version series (e.g. `2.0 2.1 ... 2.20`), finds latest patch, skips if tag exists, downloads, unzips, generates, commits, tags:
```bash
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
git -C "$ROOT_DIR" fetch --all
git -C "$ROOT_DIR" reset --hard origin/main
WP_JSON="$(wget -q -O- "https://api.wordpress.org/plugins/info/1.0/<slug>.json")"
for V in <minor-versions>; do
printf -v JQ_FILTER '."versions" | keys[] | select(test("^%s\\.%s\\.[0-9]+$"))' "${V%.*}" "${V#*.}"
LATEST="$(jq -r "$JQ_FILTER" <<<"$WP_JSON" | sort -t "." -k 3 -g | tail -n 1)"
if [ -z "$LATEST" ]; then continue; fi
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${LATEST}" >/dev/null 2>&1; then
echo "Tag exists for v${LATEST}, skipping..."
continue
fi
rm -rf "$ROOT_DIR/source/<slug>" 2>/dev/null || true
rm -f "$ROOT_DIR/source/<slug>."*.zip 2>/dev/null || true
wget -q -P "$ROOT_DIR/source/" "https://downloads.wordpress.org/plugin/<slug>.${LATEST}.zip"
unzip -q -o -d "$ROOT_DIR/source/" "$ROOT_DIR/source/<slug>.${LATEST}.zip"
rm -f "$ROOT_DIR/source/<slug>.${LATEST}.zip"
echo "Generating stubs for <PluginName> ${LATEST}..."
"$SCRIPT_DIR/generate.sh"
if git -C "$ROOT_DIR" diff-index --quiet HEAD --; then
echo "No changes for ${LATEST}, skipping commit..."
else
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${LATEST}"
git -C "$ROOT_DIR" tag "v${LATEST}"
fi
done
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
```
**Minor version list format:** space-separated `MAJOR.MINOR` values, e.g. `1.0 1.1 1.2 ... 1.20 2.0 2.1`. The jq filter selects all patch versions matching `^MAJOR.MINOR.[0-9]+$` then takes the latest.
**CRITICAL jq regex note:** In jq string literals, use `[0-9]` not `\d`. The `printf -v JQ_FILTER` approach above uses `\\.[0-9]+` which renders correctly.
### `bin/release-latest-versions.sh` — Composer package
```bash
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
git -C "$ROOT_DIR" fetch --all
git -C "$ROOT_DIR" reset --hard origin/main
PACKAGIST_JSON="$(wget -q -O- "https://packagist.org/packages/<vendor>/<package>.json")"
VERSIONS=(<minor-versions>)
for V in "${VERSIONS[@]}"; do
printf -v JQ_FILTER '."package"."versions" | keys[] | select(test("^%s\\.%s\\.[0-9]+$"))' "${V%.*}" "${V#*.}"
LATEST="$(jq -r "$JQ_FILTER" <<<"$PACKAGIST_JSON" | sort -t "." -k 3 -g | tail -n 1)"
if [ -z "$LATEST" ]; then continue; fi
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${LATEST}" >/dev/null 2>&1; then
echo "Tag exists for v${LATEST}, skipping..."
continue
fi
printf -v SED_EXP 's#\("<vendor>/<package>"\): "[^"]*"#\1: "%s"#' "${LATEST}"
sed -i -e "$SED_EXP" "$ROOT_DIR/source/composer.json"
composer --working-dir="$ROOT_DIR/source" update --no-interaction
"$SCRIPT_DIR/generate.sh"
if git -C "$ROOT_DIR" diff-index --quiet HEAD --; then
echo "No changes for ${LATEST}, skipping commit..."
else
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${LATEST}"
git -C "$ROOT_DIR" tag "v${LATEST}"
fi
done
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
```
### `bin/release-latest-versions.sh` — Paid plugin
Accept VERSION as first argument, source must be pre-placed at `source/<slug>/`:
```bash
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
set -e
VERSION="${1:?Usage: $0 <version>}"
if git -C "$ROOT_DIR" rev-parse "refs/tags/v${VERSION}" >/dev/null 2>&1; then
echo "Tag v${VERSION} already exists. Skipping."
exit 0
fi
if [ ! -d "$ROOT_DIR/source/<slug>" ]; then
echo "ERROR: Place <PluginName> ${VERSION} source at source/<slug>/ first."
exit 1
fi
"$SCRIPT_DIR/generate.sh"
git -C "$ROOT_DIR" commit --all -m "Generate stubs for <PluginName> ${VERSION}"
git -C "$ROOT_DIR" tag "v${VERSION}"
git -C "$ROOT_DIR" push origin main --follow-tags
echo "Done."
```
### `.github/workflows/release.yml`
```yaml
name: Release new version
on:
push:
paths:
- ".github/workflows/release.yml"
schedule:
- cron: '0 * * * *'
jobs:
release-new-stubs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
extensions: json zip xdebug
coverage: none
tools: composer
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: "${{ runner.os }}-${{ hashFiles('**/composer.lock') }}"
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-suggest --no-progress --no-interaction --ignore-platform-reqs
- name: Set up Git user
run: |
git config user.name 'github-actions'
git config user.email 'github-actions@github.com'
- name: Release new stubs from latest version
run: bash bin/release-latest-versions.sh
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
with:
token: ${{ secrets.PERSONAL_TOKEN }}
commit-message: Generate stubs from latest version
committer: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
author: ${{ github.actor }} <${{ github.actor_id }}+${{ github.actor }}@users.noreply.github.com>
signoff: false
delete-branch: true
title: 'Generate stubs from latest version'
body: |
This PR updates stubs from the latest available version.
labels: |
update
automated pr
assignees: my-org
draft: false
```
Omit this workflow entirely for `paid` source type.
### `phpstan.neon`
```neon
parameters:
paths:
- <slug>-stubs.php
scanFiles:
- <slug>-constants-stubs.php
bootstrapFiles:
- configs/bootstrap.php
level: 5
ignoreErrors:
- '#but return statement is missing\.$#'
- '#has an unused parameter#'
- '#^(Property|Static property|Method|Static method) \S+ is unused\.$#'
- '#is never read, only written\.$#'
- '#has invalid (return )?type (WP_Error|WP_Customize_Manager|WP_Theme|WP_User|WP_Site|WP_Upgrader)#'
```
### `.gitignore`
```
/vendor/
/composer.lock
/report.txt
```
### `.gitattributes`
```
/.gitattributes export-ignore
/.gitignore export-ignore
/.travis.yml export-ignore
/source export-ignore
```
### `.editorconfig`
```
root = true
[*]
charset = utf-8
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.yml]
indent_size = 2
```
## Execution Steps
After writing all files:
1. **Init git and make first commit:**
```bash
cd /path/to/phpstan-<slug>-stubs
git init
git branch -m trunk main
git add .
git commit -m "chore: initial scaffold for <PluginName> stubs"
```
2. **Create GitHub repo** (public):
```bash
gh repo create my-org/phpstan-<slug>-stubs \
--public \
--description "<PluginName> function and class declaration stubs for static analysis." \
--source=. \
--remote=origin \
--push
```
Then ensure default branch is `main`:
```bash
gh repo edit my-org/phpstan-<slug>-stubs --default-branch main
```
3. **Add GitHub topics:**
```bash
gh repo edit my-org/phpstan-<slug>-stubs \
--add-topic phpstan \
--add-topic php \
--add-topic stubs \
--add-topic wordpress \
--add-topic <slug>
```
4. **Install local dependencies:**
```bash
composer install --ignore-platform-reqs
```
5. **Run first release** (if not `paid`):
```bash
bash bin/release-latest-versions.sh
```
For `paid`: instruct user to place source at `source/<slug>/` then run `bash bin/release-latest-versions.sh <VERSION>`.
## Common Mistakes to Avoid
| Mistake | Correct |
|---------|---------|
| `\d` in jq string literal | Use `[0-9]` |
| `git reset --hard origin/main` before committing fix | Always push fixes before re-running release |
| `unzip` without `-o` flag | Always use `unzip -q -o` to avoid interactive prompts |
| `find -exec rm -rf {} +` with `set -e` | Use `rm -rf source/<slug> 2>/dev/null || true` |
| `gh repo create` defaults to `trunk` branch | Always rename: `git branch -m trunk main` then `gh repo edit --default-branch main` |
| Packagist API: `repo.packagist.org/p2/` | Correct URL: `packagist.org/packages/<vendor>/<package>.json` |
| Packagist JSON path: `packages.x[]` | Correct path: `."package"."versions"` |
| Missing `source/composer.json` for wp-plugin | Creates `post-install-cmd` failure; always create `source/composer.json` |Use when asked to audit a WordPress plugin for inconsistencies, run a consistency/quality sweep, or "find inconsistencies" across code and docs. Fans out parallel checks across dimensions and verifies every finding before reporting.
# WordPress Plugin Consistency Audit
Read-only audit that surfaces inconsistencies across a WP plugin's code, config, and docs. Optimised for **recall with low false-positive rate**: every candidate is verified against the actual code before it reaches the report.
## When to use
- "Audit the plugin", "find inconsistencies", "consistency/quality sweep".
- Before a release, or after a large refactor, to catch drift.
## Method
### 1. Fan out — 4 independent dimensions (parallel agents)
Dispatch one read-only agent per dimension (Explore or general-purpose), in a single message so they run concurrently. Each returns findings with `file:line`, the inconsistent value, and the expected/canonical form.
- **A — Version & metadata.** Cross-reference every version/metadata source: plugin header (`Version`, `Requires at least`, `Requires PHP`, `Tested up to`, `Text Domain`), the version constant, `readme.txt` (`Stable tag` + Changelog + Upgrade Notice), `composer.json`, the `.pot` `Project-Id-Version`, and the schema/DB version. Flag every mismatch; note fields that are *intentionally* independent (schema `$db_version` ≠ plugin version) so they aren't flagged.
Also audit the **main plugin file header format** against the canonical PHPDoc DocBlock style (preferred over plain block comment):
```php
/**
* Plugin Name
*
* @package PluginPackage
* @author Your Name
* @copyright 2024 Your Name or Company Name
* @license GPL-2.0-or-later
*
* @wordpress-plugin
* Plugin Name: Plugin Name
* Plugin URI: https://example.com/plugin-name
* Description: Description of the plugin.
* Version: 1.0.0
* Requires at least: 5.2
* Requires PHP: 7.2
* Author: Your Name
* Author URI: https://example.com
* Text Domain: plugin-slug
* License: GPL v2 or later
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Update URI: https://example.com/my-plugin/
* Requires Plugins: my-plugin, yet-another-plugin
*/
```
Flag: missing `@wordpress-plugin` marker (distinguishes WP header from plain PHPDoc); missing `@package`/`@author`/`@copyright`/`@license` PHPDoc fields; `Description` over 140 characters; `License` slug not matching `License URI`; missing `Text Domain` when plugin has translated strings (requires Dimension B `__()` detection to confirm strings exist); using a plain `/* */` block instead of `/** */` PHPDoc block.
- **B — Naming / prefix / i18n.** Canonical prefix (e.g. `myplugin_` / `_myplugin_`) — flag legacy prefixes outside the migration file. Text-domain consistency on every `__()`/`_e()`/`esc_html__()`/`_n()`; missing translator comments on `sprintf`/`printf` with placeholders. `@package` tag variants. Option/transient/hook/REST/cookie/nonce/AJAX/asset-handle/CSS-class prefix uniformity.
- **C — Docs ↔ code.** Path references (renamed dirs), function/class/option/table names referenced in docs that no longer match code, documented commands that don't exist (`composer test:unit` etc.), test counts, architecture trees vs real files, behavior claims that contradict code. Distinguish *historical* docs (point-in-time, leave) from *current* docs (must match).
- **D — Code conventions.** DB-write style (ORM vs `$wpdb` per the repo's CLAUDE.md), docblock style, `@since` tags, capability/nonce coverage, return-type consistency, leftover renamed-dir refs, duplicated logic, escaping/sanitization uniformity. Tag each: bug-risk / convention / cosmetic.
Scale dimensions to the plugin; add domain-specific ones (REST security, capability model) when relevant.
### 2. Verify EVERY candidate before reporting
Do not trust agent output verbatim — agents over-report. For each finding, `grep`/`Read` the exact line and confirm it is real. Kill false positives. Real example caught this way: an `action_links` `sprintf('<a href="%s">%s</a>', …)` flagged for a "missing translator comment" is **not** translatable (pure HTML markup) → drop it.
### 3. Report (findings only — do NOT fix unless asked)
- Group by severity: 🔴 functional → 🟠 (security/i18n) → 🟡 stale/naming → ⚪ cosmetic.
- Each finding: `file:line`, what's wrong, what's correct.
- Include a **"Checked, NOT bugs"** section listing intentional patterns (so they don't get "fixed" by mistake): e.g. webhook with no capability check (HMAC-protected), ORM `update()` that routes to `$wpdb` internally, intentional legacy prefixes in the migration file.
- End with a suggested fix priority. Then ask whether to fix all or a subset.
## Notes
- Audit is read-only. Run on the current working tree (mention if it includes unmerged changes).
- When fixing afterward: many files span multiple finding-categories — a single cohesive "fix audit findings" commit with an enumerated body is cleaner than fragile per-scope partial staging.
- For authoritative WP.org Plugin Directory guideline compliance (18 official rules: GPL, naming, trialware, external services), use the official `wp-plugin-directory-guidelines` skill (WordPress/agent-skills). This audit covers *code consistency and conventions*; that skill covers *directory submission rules*.
## References
- `references/checklist.md` — per-dimension grep commands, common false positives to kill, and the severity-grouped report template.
- `references/readme-txt.md` — readme.txt required/optional sections, field limits, Stable tag rules, common mistakes, and verification greps.
- `references/escaping-sanitization.md` — escaping functions by context, sanitization functions by input type, late-escape rule, common XSS/SQLi flags.
- `references/capability-nonce.md` — nonce creation/verification patterns, capability map, public webhook exception, false-positive patterns.
- `references/i18n-translator-comments.md` — all i18n function signatures, translator comment format and placement rules, variable/placeholder rules, common flags.Use when bumping or releasing a WordPress plugin version, syncing version numbers, or updating readme.txt / changelog. Keeps every version source coherent so Stable tag, header, and constant never drift.
# WordPress Plugin Release / Version Sync
Bump a WP plugin version coherently. Prevents the classic drift where the plugin header says one version, the `readme.txt` `Stable tag` another, and the `.pot` a third.
## When to use
- "Release X.Y.Z", "bump the version", "update the changelog / readme.txt".
- After substantial work has landed under an unreleased version.
## First: determine the real current state
```bash
git tag # any release tags?
gh release list # any published releases?
grep -n "Version:" *.php # plugin header
grep -n "_VERSION'" *.php # version constant
grep -n "Stable tag" readme.txt
```
If header/constant/Stable-tag disagree, that drift IS the problem — pick the target version and sync all of them. Choose the bump by semver: new backward-compatible features → minor; fixes only → patch; breaking → major. Internal-only refactors (dir rename) don't force a major.
## Sources to update (all, in lockstep)
1. **Plugin header** `* Version: X.Y.Z` (main plugin file).
2. **Version constant** `define( 'PLUGIN_VERSION', 'X.Y.Z' )`.
3. **`readme.txt` `Stable tag: X.Y.Z`** — and `Tested up to` / `Requires PHP` if they changed.
4. **`readme.txt` Changelog** — add a `= X.Y.Z =` block listing what shipped (security, features, fixes), grouped.
5. **`readme.txt` Upgrade Notice** — add `= X.Y.Z =` one-liner (why upgrade).
6. **`.pot`** — regenerate so `Project-Id-Version` matches and new strings are captured:
```bash
composer makepot # or: wp i18n make-pot . languages/<slug>.pot --exclude=...
```
## Do NOT bump
- **Schema / DB version** (e.g. `LicenseModel::$db_version`) — independent of plugin version. Only bump when the table actually changed, since it gates data migrations.
- Historical changelog entries or point-in-time docs.
## Verify
```bash
composer lint && composer analyze && composer test
grep -rn "X\.Y\.Z\|<old version>" --include=*.php --include=readme.txt . # confirm sync, spot stragglers
```
Then commit (`docs:`/`chore:` for a pure version+readme bump), and ship via the repo's contribution flow (branch → PR → merge; never squash if the repo says so).
## References
- `references/readme-txt-skeleton.txt` — full WP.org `readme.txt` skeleton (all sections) with the release-sync checklist of every version source baked in as a trailing comment.Use when setting up or writing tests for a WordPress plugin — PHPUnit integration tests with WP test suite, unit tests with WP_Mock or Brain\Monkey, acceptance tests with wp-browser/Codeception, factory-based fixtures, HTTP request mocking, or multisite test scaffolding.
# WordPress Plugin Testing
Set up and write automated tests for WordPress plugins: PHPUnit integration tests (real WP + DB), pure unit tests (no WP loaded), and acceptance/E2E tests with Codeception.
## When to use
- "Set up PHPUnit tests for my plugin", "bootstrap a WP test suite", "scaffold plugin tests".
- "Write a unit test for this function", "mock WordPress functions without loading WP".
- "Set up Codeception / wp-browser", "write acceptance tests".
- "Test a hook callback", "assert a filter changes the output", "test AJAX handlers".
- "Add tests to CI", "run tests on GitHub Actions".
**Not for:** PHPStan static analysis — use the official `wp-phpstan` skill. Scaffolding a stubs package for a third-party library — use `wp-phpstan-stubs`. Debugging CI failures on an existing suite — use `wp-ci-qa`.
## Method
### 1. Choose test type
| Type | Tool | WP loaded | DB | Speed |
|---|---|---|---|---|
| Integration | PHPUnit + WP test suite (`WP_UnitTestCase`) | ✅ Full | ✅ Real (temp) | Slow |
| Unit | PHPUnit + Brain\Monkey (recommended) or WP_Mock | ❌ | ❌ | Fast |
| Acceptance | Codeception + wp-browser | ✅ Browser | ✅ Real | Slowest |
Start with integration tests for hooks/filters; unit tests for pure business logic; acceptance only for critical user flows.
### 2. Integration test setup (WP test suite)
**Install test suite:**
```bash
# WP-CLI method (recommended)
wp scaffold plugin-tests my-plugin
# Manual — install WP test library
bash bin/install-wp-tests.sh wordpress_test root '' localhost latest
```
`bin/install-wp-tests.sh` creates a temp WP installation and the `wordpress-tests-lib`. Add `tests/` dir to `.gitignore` if downloading WP inline, or commit the bootstrap only.
**`composer.json` additions:**
```json
{
"require-dev": {
"phpunit/phpunit": "^9.0 || ^10.0",
"yoast/phpunit-polyfills": "^2.0"
},
"scripts": {
"test": "phpunit",
"test:unit": "phpunit --testsuite=unit",
"test:integration": "phpunit --testsuite=integration"
}
}
```
**`phpunit.xml.dist`:**
```xml
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap.php" colors="true">
<testsuites>
<testsuite name="integration">
<directory>tests/integration</directory>
</testsuite>
<testsuite name="unit">
<directory>tests/unit</directory>
</testsuite>
</testsuites>
</phpunit>
```
**`tests/bootstrap.php` (integration):**
```php
<?php
$_tests_dir = getenv( 'WP_TESTS_DIR' ) ?: '/tmp/wordpress-tests-lib';
require_once $_tests_dir . '/includes/functions.php';
tests_add_filter( 'muplugins_loaded', function() {
require dirname( __DIR__ ) . '/my-plugin.php';
} );
require $_tests_dir . '/includes/bootstrap.php';
```
### 3. Write integration tests
Extend `WP_UnitTestCase` (provided by WP test suite). It wraps each test in a DB transaction and rolls back — no teardown needed for posts/users/terms.
```php
class Test_My_Feature extends WP_UnitTestCase {
public function test_filter_changes_title() {
$post_id = self::factory()->post->create( [ 'post_title' => 'Original' ] );
// Activate the plugin feature
add_filter( 'the_title', 'my_plugin_modify_title', 10, 2 );
$title = get_the_title( $post_id );
$this->assertStringContainsString( 'Modified', $title );
}
public function test_option_saved_on_activation() {
do_action( 'activate_my-plugin/my-plugin.php' );
$this->assertSame( '1.0.0', get_option( 'my_plugin_version' ) );
}
public function test_ajax_handler_returns_json() {
// Simulate AJAX call
$_POST['_wpnonce'] = wp_create_nonce( 'my_action' );
$_POST['data'] = 'test';
try {
$this->_handleAjax( 'my_plugin_action' );
} catch ( WPAjaxDieContinueException $e ) {
// Normal for wp_send_json_success
}
$response = json_decode( $this->_last_response, true );
$this->assertTrue( $response['success'] );
}
}
```
**Factory helpers:**
```php
$user_id = self::factory()->user->create( [ 'role' => 'editor' ] );
$term_id = self::factory()->term->create( [ 'taxonomy' => 'category', 'name' => 'News' ] );
$post_ids = self::factory()->post->create_many( 5, [ 'post_status' => 'publish' ] );
$attachment = self::factory()->attachment->create_upload_object( '/path/to/image.jpg' );
```
### 4. Unit tests with Brain\Monkey (or WP_Mock)
For pure functions that don't need a real WP environment. **Brain\Monkey** is the recommended choice — it includes Mockery, has first-class `stubEscapeFunctions()` / `stubTranslationFunctions()` helpers, and richer hook assertion API. WP_Mock (10up) is a lighter alternative.
```bash
# Brain\Monkey (recommended)
composer require --dev brain/monkey mockery/mockery yoast/phpunit-polyfills
# WP_Mock (alternative)
composer require --dev 10up/wp_mock
```
See `references/brain-monkey-patterns.md` for the full base-class pattern (including `ReflectsObjects` for testing private/protected members) that matches real-world complex plugin structures.
**`tests/bootstrap-unit.php`:**
```php
<?php
WP_Mock::bootstrap();
require dirname( __DIR__ ) . '/includes/functions.php'; // file under test
```
**Test:**
```php
use WP_Mock\Tools\TestCase;
class Test_Pure_Function extends TestCase {
public function setUp(): void {
parent::setUp();
WP_Mock::setUp();
}
public function tearDown(): void {
WP_Mock::tearDown();
parent::tearDown();
}
public function test_get_plugin_option_returns_default() {
WP_Mock::userFunction( 'get_option' )
->with( 'my_plugin_setting', 'default_value' )
->andReturn( 'default_value' );
$result = my_plugin_get_setting();
$this->assertSame( 'default_value', $result );
WP_Mock::assertActionsCalled();
}
public function test_action_fires_on_save() {
WP_Mock::expectAction( 'my_plugin_after_save', 42 );
WP_Mock::userFunction( 'update_option' )->andReturn( true );
my_plugin_save_data( 42 );
}
}
```
### 5. HTTP request mocking
Intercept `wp_remote_get/post` in integration tests:
```php
// In setUp or individual test
add_filter( 'pre_http_request', function( $preempt, $args, $url ) {
if ( str_contains( $url, 'api.example.com' ) ) {
return [
'response' => [ 'code' => 200, 'message' => 'OK' ],
'body' => wp_json_encode( [ 'status' => 'ok', 'data' => [] ] ),
'headers' => [],
'cookies' => [],
];
}
return $preempt;
}, 10, 3 );
```
### 6. Multisite tests
```php
class Test_Network_Feature extends WP_UnitTestCase {
public static function setUpBeforeClass(): void {
parent::setUpBeforeClass();
if ( ! is_multisite() ) {
self::markTestSkipped( 'Multisite required.' );
}
}
public function test_option_per_site() {
$site_id = self::factory()->blog->create();
switch_to_blog( $site_id );
update_option( 'my_plugin_setting', 'site-value' );
restore_current_blog();
switch_to_blog( $site_id );
$value = get_option( 'my_plugin_setting' );
restore_current_blog();
$this->assertSame( 'site-value', $value );
}
}
```
Run multisite tests: `WP_MULTISITE=1 vendor/bin/phpunit`
### 7. Testing redirect + exit paths
Production code commonly ends with:
```php
wp_safe_redirect( $url );
exit;
```
`add_filter( 'wp_redirect', '__return_false' )` stops the header but **not** `exit` — the PHP process dies, PHPUnit prints no summary, and all subsequent tests never run.
**Fix: throw from the filter to unwind the stack before `exit` is reached.**
```php
// tests/Support/Redirect.php — own PSR-4 file so every test class can catch it
namespace MyPlugin\Test;
class Redirect extends \Exception {
public string $location;
public function __construct( string $location ) {
parent::__construct( 'redirect' );
$this->location = $location;
}
}
```
```php
class Test_With_Redirect extends WP_UnitTestCase {
private $redirect_filter;
public function setUp(): void {
parent::setUp();
// Whitelist external hosts exactly as production does
add_filter( 'allowed_redirect_hosts', fn( $h ) => array_merge( $h, [ 'dashboard.example.com' ] ) );
$this->redirect_filter = static fn( $loc ) => throw new \MyPlugin\Test\Redirect( $loc );
add_filter( 'wp_redirect', $this->redirect_filter );
}
public function tearDown(): void {
remove_filter( 'wp_redirect', $this->redirect_filter );
parent::tearDown();
}
private function run(): ?string {
try {
my_plugin_do_thing_that_may_redirect();
} catch ( \MyPlugin\Test\Redirect $e ) {
return $e->location;
}
return null;
}
public function test_redirects_on_success(): void {
$location = $this->run();
$this->assertSame( 'https://dashboard.example.com/', $location );
$this->assertSame( $user_id, get_current_user_id() ); // side effects before exit
}
public function test_no_redirect_on_error(): void {
$this->assertNull( $this->run() );
}
}
```
Copy-paste harness: `references/example-test.php`. AJAX / REST / `wp_die()` patterns: `references/redirect-assertions.md`.
### 8. GitHub Actions CI
```yaml
# .github/workflows/phpunit.yml
name: PHPUnit
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test
options: --health-cmd="mysqladmin ping" --health-interval=10s
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
extensions: mysqli
tools: composer, wp-cli
- run: composer install --no-interaction --prefer-dist
- name: Install WP test suite
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 latest
- run: vendor/bin/phpunit --testsuite=integration
- run: vendor/bin/phpunit --testsuite=unit
```
## Notes
- `WP_UnitTestCase` rolls back DB after each test — use `self::factory()`, not raw `wp_insert_post()`, so rollback is tracked.
- Integration tests require a real MySQL database; they're slow in CI. Separate unit and integration into distinct test suites and run unit suite on every push, integration suite on PRs only.
- For WooCommerce plugin tests, include WC's test helpers: `require WC_ABSPATH . 'tests/legacy/includes/wc-helper-product.php'`.
- Codeception + wp-browser is the recommended path for acceptance tests; see `https://wpbrowser.wptestkit.dev` for full docs.
- `references/redirect-assertions.md` covers AJAX (`WP_Ajax_UnitTestCase`), REST, and `wp_die()` assertion patterns. `references/phpunit-bootstrap.md` has the full bootstrap + CI setup.Use when building, extending, or debugging a WooCommerce plugin — custom product types, payment gateways, shipping methods, REST API extensions, WC hooks, CRUD with WC_Product/WC_Order/WC_Customer, admin columns/tabs, cart/checkout blocks, or WooCommerce subscription logic.
# WooCommerce Extension Development
Guide for building WooCommerce extensions: custom product types, payment gateways, hooks, CRUD, REST, and admin UI. Assumes the host plugin passes the `wp-plugin-audit` baseline and the official `wp-plugin-development` security conventions.
## When to use
- "Add a custom product type", "create a payment gateway", "add a shipping method".
- "Extend the WooCommerce REST API", "add fields to WC orders/products".
- "Build a WooCommerce admin tab", "add product meta", "custom checkout field".
- "Hook into WC cart/checkout", "add a fee", "apply a discount programmatically".
- "Debug WooCommerce order status flow", "fix a WC hook not firing".
**Not for:** General WordPress plugin architecture — use `wp-plugin-development`. PHPStan types for WC — use `wp-phpstan-stubs` to scaffold WC stubs.
## Method
### 1. Identify extension point category
Determine which WC subsystem applies before writing code:
| Goal | Subsystem |
|---|---|
| Custom product type | `WC_Product` subclass + `product_type_query` filter |
| Payment gateway | `WC_Payment_Gateway` subclass + `woocommerce_payment_gateways` filter |
| Shipping method | `WC_Shipping_Method` subclass + `woocommerce_shipping_methods` filter |
| Custom order status | `wc_register_order_status` + `wc_order_statuses` filter |
| Cart/checkout field | `woocommerce_checkout_fields` filter or block integration API |
| Admin product tab | `woocommerce_product_data_tabs` + `woocommerce_product_data_panels` |
| Order list column | `manage_edit-shop_order_columns` + `manage_shop_order_posts_custom_column` |
| REST API extension | `woocommerce_rest_*` hooks or custom endpoint on `WC_REST_Controller` |
### 2. CRUD — use WC classes, not direct `$wpdb`
Always use WC CRUD methods; they fire the correct hooks and invalidate caches.
```php
// Orders
$order = wc_create_order( [ 'status' => 'pending', 'customer_id' => $user_id ] );
$order->add_product( wc_get_product( $product_id ), 1 );
$order->calculate_totals();
$order->save();
// Products
$product = new WC_Product_Simple();
$product->set_name( 'My Product' );
$product->set_regular_price( '19.99' );
$product->set_status( 'publish' );
$product->save();
// Reading
$order = wc_get_order( $order_id ); // returns WC_Order or false
$product = wc_get_product( $product_id ); // returns WC_Product subclass or false
```
For meta, use `$order->get_meta()` / `$order->update_meta_data()` + `$order->save()` — never `update_post_meta()` on orders (breaks HPOS).
### 3. HPOS compatibility
WooCommerce 8.2+ ships **High-Performance Order Storage** (HPOS). Extensions must declare compatibility or they're disabled in HPOS stores.
```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
);
}
} );
```
Rules under HPOS:
- Never read/write orders via `get_post_meta()` / `update_post_meta()` — use `WC_Order` getters/setters.
- Never query orders via `WP_Query` with `post_type=shop_order` — use `wc_get_orders()`.
- Avoid `$wpdb` queries directly on `{prefix}posts` for order data.
### 4. Payment gateway skeleton
```php
class My_Payment_Gateway extends WC_Payment_Gateway {
public function __construct() {
$this->id = 'my_gateway';
$this->method_title = __( 'My Gateway', 'my-plugin' );
$this->method_description = __( 'Pay via My Gateway.', 'my-plugin' );
$this->supports = [ 'products', 'refunds' ];
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title' );
$this->enabled = $this->get_option( 'enabled' );
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id,
[ $this, 'process_settings' ] );
}
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
// ... call payment API ...
$order->payment_complete( $transaction_id );
return [ 'result' => 'success', 'redirect' => $this->get_return_url( $order ) ];
}
public function process_refund( $order_id, $amount = null, $reason = '' ) {
// return true on success, WP_Error on failure
}
}
add_filter( 'woocommerce_payment_gateways', fn( $gateways ) => [ ...$gateways, My_Payment_Gateway::class ] );
```
### 5. REST API extension
Extend existing WC REST endpoints via `woocommerce_rest_prepare_*` hooks, or register a custom controller:
```php
// Add field to products REST response
add_filter( 'woocommerce_rest_prepare_product_object', function( $response, $product, $request ) {
$response->data['my_custom_field'] = $product->get_meta( '_my_field' );
return $response;
}, 10, 3 );
// Accept field on write
add_filter( 'woocommerce_rest_pre_insert_product_object', function( $product, $request ) {
if ( isset( $request['my_custom_field'] ) ) {
$product->update_meta_data( '_my_field', sanitize_text_field( $request['my_custom_field'] ) );
}
return $product;
}, 10, 2 );
```
### 6. Key hooks reference
```php
// Cart
add_action( 'woocommerce_cart_calculate_fees', [ $this, 'add_fee' ] );
add_filter( 'woocommerce_cart_item_price', [ $this, 'modify_price' ], 10, 3 );
// Checkout
add_filter( 'woocommerce_checkout_fields', [ $this, 'add_field' ] );
add_action( 'woocommerce_checkout_update_order_meta', [ $this, 'save_field' ] );
// Orders
add_action( 'woocommerce_order_status_changed', [ $this, 'on_status_change' ], 10, 4 );
add_filter( 'wc_order_statuses', [ $this, 'register_status' ] );
// Products
add_filter( 'woocommerce_product_data_tabs', [ $this, 'add_tab' ] );
add_action( 'woocommerce_product_data_panels', [ $this, 'render_panel' ] );
add_action( 'woocommerce_process_product_meta', [ $this, 'save_meta' ] );
```
### 7. Blocks (cart/checkout) compatibility
Classic shortcode hooks (`woocommerce_checkout_fields`) do **not** fire for the block-based checkout. Use the Store API extension registry:
```php
add_action( 'woocommerce_blocks_loaded', function() {
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) return;
woocommerce_store_api_register_endpoint_data( [
'endpoint' => Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema::IDENTIFIER,
'namespace' => 'my-plugin',
'schema_callback' => fn() => [ 'my_field' => [ 'type' => 'string' ] ],
'data_callback' => fn() => [ 'my_field' => get_user_meta( get_current_user_id(), '_my_field', true ) ],
] );
} );
```
Declare blocks compatibility alongside HPOS:
```php
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
```
## Notes
- Always check `class_exists( 'WooCommerce' )` before any WC code; gate with `woocommerce_loaded` action.
- Minimum WC version requirements: HPOS stable in 8.2, blocks checkout stable in 8.3.
- Use `wc_get_logger()` for debug logging — writes to **WooCommerce → Status → Logs**, not the WP debug log.
- For testing: WC ships test helpers in `woocommerce/tests/legacy/includes/` — use `WC_Helper_Product::create_simple_product()` etc. in PHPUnit tests.
## References
- `references/wc-hooks.md` — categorised hook list (cart, checkout, orders, products, admin) with signatures and since versions.
- `references/hpos-migration.md` — HPOS compatibility checklist and query migration patterns.
- `references/product-crud.md` — WC_Product factory, meta CRUD, product type registration, variation patterns.
- `references/rest-api.md` — WC REST API auth, endpoints, batch operations, extending product/order responses via filters.
- `references/block-cart-checkout.md` — SlotFills, registerCheckoutFilters, extensionCartUpdate, woocommerce_store_api_register_update_callback, enqueue pattern.
- `references/payment-methods.md` — registerPaymentMethod(), registerExpressPaymentMethod(), AbstractPaymentMethodType PHP class, block payment registration.
- `references/payment-gateway.md` — WC_Payment_Gateway scaffold, process_payment(), process_refund(), webhook handler, settings fields.
- `references/shipping.md` — WC_Shipping_Method scaffold, calculate_shipping(), woocommerce_package_rates filter, zone handling, split packages.
- `references/orders.md` — wc_get_orders(), getter list, line item iteration, status hooks, custom status registration, wc_create_refund(), HPOS admin columns.
- `references/coupons-tax-webhooks.md` — WC_Coupon CRUD, wc_order_statuses filter, WC_Tax::calc_tax(), WC_Webhook programmatic creation, HMAC-SHA256 verification.