wp-dev-skills logo

wp-dev-skills

0

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).

18 skills

wp-admin-browser

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).

wp-background-processing

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()`.

wp-build-tools

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.

wp-ci-qa

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 |

wp-coding-standards

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`).

wp-database

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.

wp-email-templates

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: `&copy;` 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.

wp-freemius

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.

wp-github-flow

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`. |

wp-guided-tour

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 exists

wp-i18n-workflow

Use 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`.

wp-multisite

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.

wp-org-submission

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.

wp-phpstan-stubs

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` |

wp-plugin-audit

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.

wp-plugin-release

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.

wp-plugin-testing

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.

wp-woocommerce

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.