# Wp Plugin Standards

> WordPress.org plugin submission standards, code quality, and documentation discipline. Covers Plugin Check (PCP) compliance, security hardening, code reuse via shared utility classes, DocBlock documentation, version tracking, and CHANGELOG discipline. Use when writing, editing, reviewing, or preparing any WordPress plugin for WordPress.org submission.

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

---


# WordPress Plugin Standards

Companion to `wp-plugin-development`. Apply this skill when a plugin must pass
WordPress.org Plugin Check (PCP), or when improving code quality, documentation,
or submission readiness.

## When to use this skill

- Writing or modifying WordPress plugin PHP files
- Preparing a plugin for WordPress.org submission
- Reviewing plugin code for security, quality, or standards compliance
- Any task where plugin code reuse, documentation, or versioning is involved

## Mandatory workflow

**Step 0 is always required and may never be skipped.** Do not modify any file
until the user explicitly confirms they want to proceed after reviewing the report.

### Step 0 — Review report (always first)

**Before scanning files, derive the expected WordPress.org slug:**
Take the `Plugin Name:` header value, lowercase it, replace spaces with hyphens, and strip special characters. This is the expected `Text Domain:` value. If the plugin header `Text Domain:` does not match this derived slug, flag it as **Critical** immediately — this generates 70+ errors in PCP. Example: "CloudScale Free Backup and Restore" → `cloudscale-free-backup-and-restore`.

**Then review against the 18 Detailed Plugin Guidelines** (`references/wordpress-org-guidelines.md`) — these are the human-reviewer policy rules that Plugin Check does **not** catch and that cause most repeat rejections of otherwise-clean code. Explicitly check: trialware / locked features (G5), tracking without explicit opt-in (G7), "Powered by"/credit links not opt-in (G10), admin-notice hijacking (G11), bundling libraries WordPress ships (G13), third-party CDN / admin iframes / external code loading (G8), obfuscated or minified-only code (G4), GPL-incompatible bundled assets (G1), and readme spam (G12). Run the audit greps in that reference file.

Scan all plugin files and produce a findings report grouped by severity before
doing anything else. Use this exact format:

---

**WordPress Plugin Standards — Review**

**Critical** — blocks WordPress.org submission
- `file.php:123` Description of violation

**High** — security or data integrity risk
- `file.php:78` Description of violation

**Medium** — code quality, documentation, reuse
- `file.php:12` Description of violation

**Low** — style, naming, minor standards
- `file.php:34` Description of violation

**Passed**
- Areas with no violations found

X critical · X high · X medium · X low issues found.
Confirm to proceed with all fixes, or specify which severity levels to address.

---

Severity definitions:

| Severity | Examples |
|----------|---------|
| Critical | Echoed `<script>` or `<style>` tags, missing nonce, unescaped output, raw SQL, WordPress.org ownership or contributor mismatch (`Contributors:` in readme.txt must contain the **actual WordPress.org login username** of the plugin owner — not a brand slug like `cloudscale`; mismatch triggers automated warning before human review), `Author URI` containing a placeholder domain (`example.com` / `example.org` / `example.net`) — automated hard-reject (`plugin_header_invalid_author_uri_domain`), **any URL in readme.txt returning HTTP 404** — the automated pre-reviewer validates `Plugin URI`, `Author URI`, and all links in `== External services ==` (Terms, Privacy) before the submission reaches a human; 404s are reported as failures, hidden files (dot-files) present in the plugin directory, admin page reachable without authentication, REST endpoint with `'__return_true'` permission callback **for an endpoint that (a) returns non-public data (counts or details for private/draft posts, user-specific data) or (b) processes writes without a per-object `current_user_can()` check** — `__return_true` is permitted only when every piece of data returned is already anonymously visible AND the handler itself gates on `get_post_status( $id ) === 'publish'` before returning any per-object data; write endpoints must use `current_user_can( 'edit_post', $id )` or equivalent regardless of nonce validity — a `wp_rest` nonce authenticates session context, not the caller's capability over specific content, `unserialize()` on user-supplied data, file upload without MIME validation, any `shell_exec()`, `exec()`, `system()`, `passthru()`, `proc_open()`, or `popen()` call (WordPress.org reviewers require **complete removal** — `escapeshellarg()` and `phpcs:ignore` do **not** satisfy the review; the plugin will be rejected regardless of how arguments are sanitised), prefix shorter than 4 characters (e.g. `cs_`), files written to the plugin directory or to `WP_CONTENT_DIR` via a write function (`PluginCheck.CodeAnalysis.WriteFile.PluginDirectoryWrite`), executable code (`.php`/`.sh`) deployed to disk at runtime by any means — runtime generation **or** `copy()`/`rename()` of a bundled static file — to any destination **including the uploads directory**, remote asset offloading from own server/CDN, WP-Cron callback registered via bare `add_action()` without a `Throwable`-catching wrapper, i18n string with a printf placeholder missing the `/* translators: */` comment (`WordPress.WP.I18n.MissingTranslatorsComment`), **trialware** — functionality disabled until payment / trial-expiry / licence-key gating (Guideline 5), **tracking/phoning-home without explicit opt-in** (default-off) (Guideline 7), **bundling a library WordPress already ships** (own copy of jQuery / PHPMailer / SimplePie / Backbone, etc.) (Guideline 13), **loading code or assets from a third-party CDN** (non-font) or **embedding admin pages via an external `<iframe>`** or **installing plugins/themes from outside WordPress.org** (Guideline 8), **obfuscated code or minified-only JS/CSS with no source** (Guideline 4), **readme.txt `== Description ==` section over 2,500 words** — the WordPress.org importer truncates it on import and shows "The `Description` section is too long and was truncated. A maximum of 2,500 words is supported." (visible only to authors/committers); content past the limit silently vanishes from the public listing, **i18n text domain passed as a variable** (`WordPress.WP.I18n.NonSingularStringLiteralDomain`) — every `__()`, `_e()`, `esc_html__()`, `esc_attr__()`, `_x()`, `_n()` and all i18n variant calls must pass the `$domain` parameter as a **string literal**, not a variable (`$td`, `$text_domain`, `$this->td`); fires once per call — a class with 25 translated strings generates 25 Critical errors, **`application_detected`** — development/build tooling files (`phpcs.xml.dist`, `.phpcs.xml.dist`, `phpunit.xml.dist`, `package.json`, `composer.json`, `Gruntfile.js`, `webpack.config.js`) included in the distribution zip are rejected before human review, **cryptocurrency mining or botnet code** — any code that mines crypto, participates in distributed computing without explicit user consent, or commandeers server resources for third-party benefit is an immediate hard-rejection and grounds for developer account ban (Guideline 9); **audit:** `grep -rn "coinhive\|cryptonight\|monero\|nicehash\|mining\|botnet\|xmlrpc.*flood\|curl_multi_exec.*broadcast" --include=*.php`, **PHP short tags** (`<?` or `<?=`) — `short_open_tag = Off` (common on many hosts) makes them fail silently; PHPCS/PCP cannot detect missing escaping inside short-tag blocks; every occurrence is flagged, **`ALLOW_UNFILTERED_UPLOADS` set to `true`** or referenced in conditional logic — permits uploading executable `.php` files; immediate hard-rejection regardless of context or intent, **`_e()` or `_ex()` used anywhere in plugin code** — these echo the translation directly without escaping; replace every `_e('text','slug')` with `esc_html_e('text','slug')` or `esc_attr_e('text','slug')` depending on context (one error per call), **HEREDOC (`<<<EOT`) or NOWDOC (`<<<'EOT'`) syntax for any output** — PHPCS/PCP cannot trace escaping through heredoc blocks; reviewers flag all occurrences, **framework or library-only plugin** — plugins that are pure utility templates or pure dependency libraries for other plugins to import/modify are not accepted; every plugin must be self-contained, **100% duplicate of another plugin, or plugin that only reimplements WordPress core functionality** — must provide genuinely new value, **plugin programmatically activates or deactivates other plugins** (calls `activate_plugin()`, `deactivate_plugins()` outside of dependency-error handling) — violates user control, **built-in custom update checker / "phones home" to your own server to check for plugin updates** — WordPress.org provides the update service; all custom update-check code must be removed |
| High | Missing capability check, unsanitised input, bare `die()`, hardcoded URLs, missing ABSPATH guard, admin menu using `'read'` capability, `wp_redirect()` on user-supplied URL (open redirect), user-supplied URL passed to `wp_remote_get()` (SSRF), file path built from user input without traversal check, `is_admin()` used as access-control check, hardcoded API keys or credentials, IDOR (object-level capability not checked), AJAX handler using a nonce helper wrapper instead of `check_ajax_referer()` directly (`NonceVerification.Missing`), **front-end "Powered by"/credit link not opt-in and default-hidden** (Guideline 10), **non-dismissible or site-wide admin notice / dashboard nag / dashboard ad with referral tracking** (Guideline 11), **readme spam** — more than 5 tags, competitor/trademark tags, affiliate links, keyword stuffing (Guideline 12), **GPL-incompatible bundled asset** — image/font/library under a non-GPL-compatible licence (Guideline 1), **`esc_url_raw()` used as HTML output escaper** — it is a sanitiser for DB storage and redirect targets; use `esc_url()` for escaping URLs in HTML attributes and output, **`json_encode()` instead of `wp_json_encode()`** — WordPress alternative required; `json_encode()` bypasses WordPress safety checks, **`ini_set()` called at global scope** (on `init` or at file load) — affects the entire site; scope it to the specific function that needs it, **`date_default_timezone_set()` called anywhere** — WordPress expects UTC internally; calling this breaks `get_post_time()`, `current_time()`, and all WP date helpers, **`error_reporting()` present in committed code** — remove entirely; site operators control this via `WP_DEBUG`, **`filter_var()` / `filter_input()` called without a filter parameter** — `FILTER_DEFAULT` does not sanitise; always specify e.g. `FILTER_SANITIZE_NUMBER_INT`, **iterating over entire `$_POST` / `$_REQUEST` / `$_GET` in a loop** instead of accessing specific named keys, **`esc_html()` applied to content that legitimately contains HTML tags** — strips tags; use `wp_kses_post()` or scoped `wp_kses()` |
| Medium | Duplicate helper functions, missing DocBlocks, version string mismatch, missing CHANGELOG entry, global asset enqueue, `async` JS function without `try/catch` (silent failure), `catch` block with no `console.error()` or user-visible message, `getElementById()` result used without null check, `wp_ajax_nopriv_` used for an admin-only action, `maybe_unserialize()` on externally-sourced option values, **main plugin filename not matching the plugin slug** (slug is derived from `Plugin Name:` header; file must be named `slug.php`), **non-standard file types in plugin zip** (permitted: `.php`, `.js`, `.css`, `.txt`, `.md`, `.png`, `.svg`, `.jpg`, `.json`, `.xml`; anything else requires justification), **`composer.json` absent when plugin uses Composer dependencies** (prevents others from reviewing or forking the dependency tree) |
| Low | Naming convention violations, missing inline comments, non-autoloaded options, minor i18n issues, PHPCS false-positive suppressions missing for WordPress core hook names (`NonPrefixedHooknameFound`) |

**Do not proceed to Step 1 until the user replies with confirmation.**

### Step 0.5 — Mandatory mechanical grep audit

**Run these bash commands immediately after receiving confirmation. Do not skip. These greps catch violations that LLM file-reading routinely misses — they are deterministic and must always run before any file edits.**

```bash
# 1. i18n domain passed as variable (NonSingularStringLiteralDomain) — one Critical per call
grep -rn "__(\|_e(\|esc_html__(\|esc_attr__(\|_x(\|_n(\|esc_html_e(\|esc_attr_e(\|esc_html_x(\|esc_attr_x(" --include=*.php . | grep '\$[a-zA-Z_]' | grep -v "vendor/\|node_modules/"

# 2. cURL usage — hard rejection
grep -rn "curl_init\|curl_exec\|curl_multi_init\|curl_share_init\|curl_file_create" --include=*.php . | grep -v "vendor/\|node_modules/"

# 3. Shell execution — hard rejection
grep -rn "\bshell_exec\b\|\bexec(\|\bsystem(\|\bpassthru(\|\bproc_open\|\bpopen(" --include=*.php . | grep -v "vendor/\|node_modules/"

# 4. _e() / _ex() unescaped output
grep -rn "\b_e(\|\b_ex(" --include=*.php . | grep -v "vendor/\|node_modules/"

# 5. Short tags
grep -rn "<?[^p]" --include=*.php . | grep -v "vendor/\|node_modules/"

# 6. application_detected — tooling files in zip
find . -maxdepth 3 \( -name "phpcs.xml*" -o -name ".phpcs.xml*" -o -name "phpunit.xml*" -o -name "package.json" -o -name "composer.json" -o -name "Gruntfile.js" -o -name "webpack.config.js" \) | grep -v "vendor/"

# 7. OutputNotEscaped — echo/print with unescaped $variables
# Lines containing echo/print AND a $variable but NO escaping function on the same line.
# False-negative caveat: a line like `echo esc_html($a) . $b` has esc_ so it won't appear here —
# read any line with multiple concatenated variables carefully even if it passes this filter.
grep -rn "\becho\b\|\bprint\b\|\bprintf\b\|<?" --include=*.php . \
  | grep '\$[a-zA-Z_]' \
  | grep -v 'esc_html\|esc_attr\|esc_url\|esc_js\|esc_textarea\|esc_sql\|esc_xml\|wp_kses\|absint\|intval\|number_format\|wp_json_encode\|sanitize_\|true\|false\|count(\|strlen(' \
  | grep -v "vendor/\|node_modules/\|//\s*phpcs:ignore"

# 8. parse_url — must use wp_parse_url() (WordPress.WP.AlternativeFunctions.parse_url_parse_url)
grep -rn "\bparse_url\s*(" --include=*.php . | grep -v "vendor/\|node_modules/"

# 9. InputNotSanitized — raw superglobal access; each hit must be sanitized via wp_unslash + sanitize_*
grep -rn "\$_POST\b\|\$_GET\b\|\$_REQUEST\b\|\$_COOKIE\b\|\$_SERVER\b" --include=*.php . \
  | grep -v "vendor/\|node_modules/\|//\s*phpcs:ignore" \
  | grep -v "wp_unslash\|sanitize_\|absint\|intval\|check_ajax_referer\|wp_verify_nonce\|check_admin_referer"

# 10. NonPrefixedClassFound — all class/interface/trait declarations; verify each starts with plugin prefix
grep -rn "^class \|^abstract class \|^final class \|^interface \|^trait " --include=*.php . \
  | grep -v "vendor/\|node_modules/"
```

Report every hit as a Critical finding before proceeding to Step 1.

**Note on grep #7 (OutputNotEscaped):** This grep surfaces most violations but can miss a line that has *both* an escaped variable and an unescaped one (e.g. `echo esc_html($a) . $b`). After running the grep, also read every PHP file section that outputs HTML and confirm every interpolated or concatenated `$variable` is wrapped in `esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()`, or `absint()` at the point of output — not just earlier in the function.

**Note on grep #9 (InputNotSanitized):** This grep surfaces superglobal reads for review; lines that only check `isset( $_POST['key'] )` without using the value are usually fine. Validate that every line where the value is *used* wraps it as `sanitize_text_field( wp_unslash( $_POST['key'] ) )` or equivalent.

### Step 1 — Check Utils

Before writing any new function, read `includes/class-SLUG-utils.php` and confirm
the helper does not already exist there (see `references/reuse.md`).

### Step 2 — Apply fixes

Apply fixes for the severity levels the user confirmed. Use `references/security.md`,
`references/cyber-security.md`, `references/coding-standards.md`,
`references/performance.md`, and `references/accessibility.md` as you go.

### Step 3 — PCP checklist

Step through `references/pcp-checklist.md` and confirm zero remaining violations
in the addressed categories.

### Step 4 — Bump versions

Update plugin header `Version:`, `VERSION` constant, and `readme.txt` `Stable tag:`
in one operation — all three must always match.

### Step 5 — Update CHANGELOG.md

Add a dated entry for every change made (see `references/reuse.md` §3).

---

## Required file structure

```
plugin-slug/
├── plugin-slug.php                    Main file — plugin header + VERSION constant
├── readme.txt                         WordPress.org readme
├── CHANGELOG.md                       Keep a Changelog format
├── uninstall.php                      Removes all plugin data on uninstall
├── includes/
│   ├── class-plugin-slug.php          Core plugin class
│   └── class-plugin-slug-utils.php   Shared helpers — single source of truth
├── admin/
│   ├── class-plugin-slug-admin.php
│   └── partials/
├── public/
│   ├── class-plugin-slug-public.php
│   └── partials/
└── assets/
    ├── css/
    └── js/
```

---

## Non-negotiable rules

These apply to every file, every task. No exceptions.

**Security**
- Verify a nonce before processing any form, AJAX handler, or REST endpoint
- Sanitise all superglobal input on the way in; escape all output on the way out
- Use `$wpdb->prepare()` for every dynamic DB query — no interpolated SQL ever
- Gate every privileged action with `current_user_can()`

**Code reuse**
- Shared helpers live in `class-SLUG-utils.php` only — never duplicated across files
- Check Utils before writing any new function; if it exists there, call it

**Documentation**
- Every file, class, method, and function gets a full DocBlock (`@since`, `@param`, `@return`)
- Inline comments explain the *why*, not the *what*

**Version tracking**
- Plugin header `Version:`, `VERSION` constant, and `readme.txt` `Stable tag:` must always match
- `CHANGELOG.md` updated on every commit that changes behaviour

**PCP compliance**
- No echoed `<script>` or `<style>` tags anywhere — the top WordPress.org rejection reason
- No hidden files (filenames beginning with `.`) in the plugin directory — WordPress.org automated scan rejects them with `hidden_files` error. Common culprits: `.distignore`, `.gitignore`, `.DS_Store`. Exclude all dot-files from the distribution zip.
- No `error_log()`, `var_dump()`, `print_r()`, or bare `die()` in committed code
- No hardcoded URLs — use `plugin_dir_url()` and `plugin_dir_path()`
- **Text domain must match the WordPress.org plugin slug** — the slug is derived from the plugin *name*, not the folder name (e.g. "My Cool Plugin" → `my-cool-plugin`). PCP reports `textdomain_mismatch` and `WordPress.WP.I18n.TextDomainMismatch` on every i18n call if wrong. Always verify: slugify the plugin name and confirm it matches `Text Domain:` in the header.
- All enqueued assets versioned with the plugin version constant
- WordPress.org `Contributors:` field must contain the **exact WordPress.org login username** of the plugin owner/submitter — not a brand name, company slug, or display name (e.g. `andrewjbaker`, not `cloudscale`). The automated pre-reviewer warns if the submitting account's username is absent.
- All URLs in `readme.txt` must return HTTP 200 — `Plugin URI`, `Author URI`, and every link in `== External services ==` (Terms, Privacy Policy) are validated by the automated pre-reviewer before human review; 404s block submission. Verify with `curl -sI <url> | head -1` before submitting.
- **Code comments must not contain external domain names** — the automated pre-reviewer scans all file content (not just string literals) for URL-like patterns. A PHP doc comment such as `* (as listed at cloudflare.com/ips-v4)` is flagged as "Calling files remotely" even though no request is made. Remove or rewrite domain references in comments to avoid URL patterns (e.g. `// see the Cloudflare IP range documentation` instead of `// cloudflare.com/ips-v4`).
- **No `date()`** — use `gmdate()` (UTC) or `wp_date()` (localised). PCP flags `WordPress.DateTime.RestrictedFunctions.date_date` as an error on every `date()` call.
- **No `unlink()`** — use `wp_delete_file()`. PCP flags `WordPress.WP.AlternativeFunctions.unlink_unlink`.
- **No `rmdir()` or `readfile()`** — use WP Filesystem API. PCP flags these as errors.
- **`wp_die()` must receive escaped strings** — `wp_die( esc_html__( 'Forbidden', 'slug' ) )` not `wp_die( 'Forbidden' )`. PCP flags `EscapeOutput.OutputNotEscaped`.
- **Every echoed variable must be escaped** — including intermediate variables that only hold safe values (e.g. `'checked'`, hex colours, pre-computed CSS class strings). PCP's `EscapeOutput.OutputNotEscaped` fires on any unescaped variable. No exceptions. **Specific context rule:** variables echoed inside `onclick="..."` attributes must use `esc_js()`, not `esc_attr()`. A variable already sanitised earlier (e.g. `$row_ami_id = esc_attr(...)`) still triggers the error if re-echoed without wrapping — always escape at the point of output.
- **`wp_unslash()` required before every `sanitize_*()`** on superglobal input — `sanitize_text_field( wp_unslash( $_POST['field'] ?? '' ) )`. PCP flags `MissingUnslash` otherwise.
- **`InputNotSanitized`** — PCP flags `$_POST` array values even when they are validated via `array_map('intval', ...)` or `array_intersect()` against a whitelist. Add `// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised via [method]` on those lines.
- **`set_time_limit()` with non-zero values** (e.g. `set_time_limit(120)`) also triggers `Squiz.PHP.DiscouragedFunctions` — the suppress pattern applies to all values, not just `set_time_limit(0)`.
- **Schema queries (`SHOW CREATE TABLE`, `DESCRIBE`)** require `WordPress.DB.DirectDatabaseQuery.SchemaChange` in addition to `DirectQuery` and `NoCaching` in the phpcs:ignore list.
- **Multi-line `$wpdb->prepare()` calls** — when a `phpcs:ignore` comment sits on the line above a multi-line statement, it only suppresses the first line. Use `phpcs:disable` / `phpcs:enable` blocks to cover all lines of multi-line DB calls containing interpolated table names.
- **readme.txt: max 5 tags, max 150-char short description** — PCP flags both violations.
- **readme.txt `== Description ==` max 2,500 words** — the WordPress.org importer runs the Description through `wp_trim_words()` (whitespace word split) and truncates anything past 2,500 words on import, surfacing the warning "The `Description` section is too long and was truncated. A maximum of 2,500 words is supported." to authors/committers only. Truncated content is dropped from the public listing with no error to end users. Count with a whitespace split (matches `wp_trim_words`), not `str_word_count()` (which under-counts). Keep the section comfortably under the limit (target ≤ 2,400 words) and move overflow into other readme sections (FAQ, screenshots) or onto the help site. **Audit:** `awk '/^==[[:space:]]*Description[[:space:]]*==/{f=1;next} /^==[[:space:]]/{f=0} f' readme.txt | wc -w` — flag Critical if the result exceeds 2,500.
- **Direct cURL** (`curl_init`, `curl_exec`, etc.) in plugin-authored code is a **hard WordPress.org rejection** — identical treatment to `shell_exec()`. `phpcs:ignore` suppression silences PHPCS/PCP but **human reviewers will still reject it**. Every `curl_exec` call in your own code must be replaced with `wp_remote_get()` / `wp_remote_post()`. **Third-party vendor libraries** containing cURL are permitted — reviewers explicitly distinguish vendor code from plugin-specific code. The sole technically defensible exception in own code is a sub-second connect timeout requirement (e.g. AWS IMDS polling) where `wp_remote_get()` genuinely cannot substitute; suppress with `// phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init, WordPress.WP.AlternativeFunctions.curl_curl_setopt_array, WordPress.WP.AlternativeFunctions.curl_curl_exec, WordPress.WP.AlternativeFunctions.curl_curl_getinfo, WordPress.WP.AlternativeFunctions.curl_curl_close -- wp_remote_get() does not support sub-second connect timeouts` and a comment, but be aware human reviewers may still flag it.

- **cURL in drop-ins / mu-plugins / bundled `assets/*.php` is also rejected — the "HTTP API isn't loaded that early" excuse does not hold** — PCP and human reviewers scan **every** PHP file shipped in the package, including early-loading drop-ins (`fatal-error-handler.php`, `object-cache.php`, `advanced-cache.php`, `db.php`, `sunrise.php`). A drop-in that phones home (e.g. a fatal-error handler sending a crash alert) is the classic place developers reach for `curl_init()`/`curl_exec()` because `wp_remote_post()` may not yet be loaded that early — but the cURL is still flagged (`WordPress.WP.AlternativeFunctions.curl_curl_init` / `_curl_setopt_array` / `_curl_exec` / `_curl_close`) and rejected. **The correct pattern is a `function_exists()` guard that skips the request when the API is unavailable — never a cURL fallback:**
  ```php
  // Best-effort notification. By the time a plugin/theme fatal fires, wp_remote_post()
  // is normally loaded; for a rare fatal before the HTTP API loads, it simply isn't sent.
  if ( function_exists( 'wp_remote_post' ) ) {
      wp_remote_post( $url, [ 'timeout' => 5, 'blocking' => false, 'body' => $body ] );
  }
  ```
  Skipping a best-effort alert in the rare pre-bootstrap-fatal case is acceptable; shipping cURL is not. **Audit:** `grep -rn "curl_init\|curl_exec\|curl_setopt" assets/ includes/ *.php` — drop-in and asset `.php` files are easy to miss because they are not loaded through the main plugin bootstrap.
- **`set_time_limit()`** flagged as discouraged — add `// phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- required to prevent PHP timeout on large backups` on each call.
- **Direct DB queries** (`$wpdb->query()` etc.) flagged as discouraged — the complete `phpcs:ignore` comment requires **three** sniff codes, not two: `// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- [reason]`. Omitting `PluginCheck.Security.DirectDB.UnescapedDBParameter` leaves a second wave of warnings: Plugin Check traces every variable used in a DB call back to its assignment and flags it as "assigned unsafely" if the assignment is not a recognised safe source. This fires on `$table`, `$cnt`, `$ref_table`, `$date_expr` — any variable in the query string. The proper resolution depends on the variable type:
  - **Table names derived from `$wpdb->prefix`** — use `esc_sql()` at assignment: `$table = esc_sql( $wpdb->prefix . 'plugin_tablename' );`. This satisfies the sniff without a suppression comment, signals intent to reviewers, and is safe because `$wpdb->prefix` contains only alphanumeric characters and underscores.
  - **SQL expressions from an internal conditional** (e.g. `$cnt = $unique ? 'COUNT(DISTINCT visitor_hash)' : 'SUM(view_count)'`) — not user input, cannot be parameterised. Suppress: `// phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- internal SQL expression selected from a hardcoded conditional, not user input`.
  - **`$limit_sql`, `$date_expr`, `$placeholders`** — same pattern: suppress if the value is built entirely from trusted/validated internal data; fix at the assignment with `absint()` / `intval()` / `esc_sql()` if any part could be external.

- **`PluginCheck.Security.DirectDB.UnescapedDBParameter` fires even when `$wpdb->prepare()` is used** — Plugin Check's own sniff runs separately from PHPCS's `WordPress.DB.PreparedSQL.InterpolatedNotPrepared`. A query can pass `$wpdb->prepare()` correctly (all user values parameterised) and still get flagged for `UnescapedDBParameter` on the interpolated table/column names. Both suppressions are needed for each query that uses both interpolated identifiers and prepared value placeholders.
- **`NonceVerification.Missing`** — PHPCS and the WordPress.org Plugin Check only recognise nonce verification when `check_ajax_referer()`, `wp_verify_nonce()`, or `check_admin_referer()` is called **directly in the same function scope** as the `$_POST`/`$_GET`/`$_FILES` access. A helper wrapper (e.g. `ajax_check()`, `cs_verify_nonce()`) that calls `check_ajax_referer()` internally is **not** traced — every `$_POST` read below it is still flagged. Fix: replace every helper call site with a direct `check_ajax_referer()` call. Adding `phpcs:disable NonceVerification.Missing` is not a substitute — Plugin Check still flags the violation.

---

## Verification

After fixes are applied, confirm:

- [ ] Review report was produced and user confirmed before any file was touched
- [ ] PCP checklist passes with zero errors in all addressed categories
- [ ] All three version strings match
- [ ] `CHANGELOG.md` has a dated entry for this change
- [ ] No helper functions duplicated — Utils is the single source of truth
- [ ] Every new function has a DocBlock with `@since`, `@param`, and `@return`

---

## References

| File | Read when |
|------|-----------|
| `references/security.md` | Any input, output, DB, AJAX, REST, or capability work |
| `references/cyber-security.md` | Admin screens, access control, OWASP checks, file upload, SSRF, open redirect, path traversal, object injection |
| `references/coding-standards.md` | Always — naming, DocBlocks, formatting, i18n, error handling |
| `references/performance.md` | Asset enqueuing, DB queries, transients, background tasks |
| `references/accessibility.md` | Any admin UI, forms, notices, or modal dialogs |
| `references/reuse.md` | Always — Utils class, version tracking, CHANGELOG, readme.txt, uninstall |
| `references/pcp-checklist.md` | Before finalising any file — full PCP compliance checklist |
| `references/wordpress-org-guidelines.md` | **Always** — the 18 Detailed Plugin Guidelines a *human* reviewer enforces (trialware, opt-in tracking, "powered by", admin-notice hijacking, bundled libraries, CDN/iframe rules, GPL assets, readme spam). These are not caught by PCP and cause most repeat rejections |

---

## Failure modes

- **Hidden files in the distribution zip** — WordPress.org automated scanning rejects any plugin containing files whose names begin with `.` (e.g. `.distignore`, `.gitignore`, `.env`, `.DS_Store`). The error is `hidden_files: Hidden files are not permitted.` Fix: ensure every dot-file is listed in the rsync/zip exclusion rules used to build the distribution package. Check with `unzip -l plugin.zip | grep '/\.'` before submitting.
- **Echoed `<script>` or `<style>` tags** — the single most common WordPress.org rejection. Grep the entire codebase for `<script` and `<style` before submitting. Every hit is a violation. Use `wp_enqueue_script()`, `wp_add_inline_script()`, `wp_enqueue_style()`, and `wp_add_inline_style()` exclusively. See `references/performance.md`.
- **Ownership mismatch** — if the submitting WordPress.org username is not in `Contributors:`, or the account email domain does not relate to the plugin's declared URLs, the submission is held. Resolve via DNS TXT record, email change, or account transfer. See `references/pcp-checklist.md` WordPress.org submission section.
- **Global asset enqueue** — PCP flags CSS/JS enqueued on every page. Always gate on `$hook` in admin, conditional tags on frontend.
- **Missing nonce** — every AJAX handler and form needs one. The most common PCP security rejection.
- **Duplicate helpers** — always check Utils before writing. Copy-paste across files causes divergence and is a review failure.
- **Version mismatch** — if the three version strings differ, WordPress.org validation will fail.
- **Bare `die()`** — use `wp_die()` in HTTP contexts. Bare `die()` is flagged by PCP.
- **Missing ABSPATH guard** — every included PHP file needs `if ( ! defined( 'ABSPATH' ) ) { exit; }` as its first executable line.
- **Downgrading `Tested up to`** — WordPress.org automated scanning rejects plugins where `Tested up to` is **lower** than the current WordPress stable release (error: `outdated_tested_upto_header`). Never lower this value during a review. If the value appears to be a future version, verify against wordpress.org/news before acting — the version may simply be ahead of the reviewer's knowledge cutoff. Only ever raise `Tested up to`, never lower it.
- **Broken buttons after `onclick` refactor** — when removing inline `onclick` attributes from PHP-rendered HTML buttons to satisfy PCP, the replacement JS event-binding code must use a stable selector. A common mistake is leaving the binding code as `querySelector('[onclick="fnName()"]')` — this selector worked while the attribute was present but returns `null` once the `onclick` is removed, silently dropping the click handler. The symptom is a button that renders correctly but does nothing when clicked, with no JS error. **Audit rule:** after every PCP `onclick` removal, verify that (a) the button has an `id`, and (b) `addEventListener` is attached via that `id`. Never use `querySelector('[onclick=...]')` as a binding selector — it is inherently self-defeating. See `references/pcp-checklist.md` §onclick refactor checklist.
- **`NonceVerification.Missing` via helper delegation** — PHPCS and the WordPress.org Plugin Check only recognise nonce verification when `check_ajax_referer()`, `wp_verify_nonce()`, or `check_admin_referer()` is called **directly in the same handler scope**. A shared helper (e.g. `ajax_check()`, `cs_verify_nonce()`) that wraps `check_ajax_referer()` internally is **invisible** to the sniff — every `$_POST`/`$_GET`/`$_FILES` access below the helper call is flagged, and `phpcs:disable NonceVerification.Missing` does not satisfy Plugin Check. Fix: replace every helper call site with a direct `check_ajax_referer( 'action', 'field' )` call. The helper function can remain for other uses; just remove the delegation at each handler. Audit by grepping for helper calls in `wp_ajax_` actions and confirming `check_ajax_referer()` appears directly in the same closure/function body. See `references/security.md` §Delegated nonce verification for the full pattern.
- **Unhandled async function rejections (silent JS failures)** — `async` functions called from `onclick` attributes return a Promise. If that Promise rejects (due to any runtime error — including calling `.style` on a null `getElementById` result, a network failure, or non-JSON response), the rejection is silently swallowed by the browser. The function stops mid-execution with no error message, no user feedback, and no console output unless `console.error()` is explicitly called in a `catch` block. The symptom is a button that appears to work but produces no result. **Audit rule:** every `async` function must wrap its entire body in `try { ... } catch(err) { console.error(...); /* show user message */ }`. Loops that call async functions should re-enable disabled buttons in `finally {}`. All `getElementById()` results must be null-checked before property access. See `references/coding-standards.md` §JavaScript async error handling.

- **PCP errors missed during manual review** — the review skill catches patterns by reading code, but PCP runs PHPCS rules mechanically and flags things that look fine to a human reader (e.g. a variable holding `'checked'` that is never user-controlled, but still needs `esc_attr()`; or `date()` used on a timestamp the developer controls). **The only way to guarantee zero PCP errors is to run the WordPress Plugin Check plugin locally before submission.** The review skill is a guide, not a substitute for a live PCP run. Always treat PCP output as the ground truth. Critical PCP-only catches:
  - `date()` → `gmdate()` (any `date()` call, regardless of context)
  - `mt_rand()` → `wp_rand()` — PCP flags `WordPress.WP.AlternativeFunctions.rand_mt_rand` as an error
  - `parse_url()` → `wp_parse_url()` — PCP flags `WordPress.WP.AlternativeFunctions.parse_url_parse_url` as an error on every native call; always use `wp_parse_url()` instead
  - `unlink()` → `wp_delete_file()`
  - `rmdir()` / `readfile()` → WP Filesystem
  - `wp_die('string')` → `wp_die( esc_html__( 'string', 'slug' ) )`
  - **`exec()` / `shell_exec()` / `system()` / `passthru()` → must be removed entirely** — adding `phpcs:ignore` is not a fix; WordPress.org reviewers reject the plugin outright regardless of whether `escapeshellarg()` is used
  - `file_put_contents()` / `file_get_contents()` without phpcs:ignore — `WordPress.WP.AlternativeFunctions.file_system_operations_*`
  - `fopen()` on remote URLs — use `wp_remote_get()` / `wp_remote_post()`; many hosts block PHP stream wrappers for remote access
  - `fread()` / `fclose()` / `fwrite()` → WP Filesystem API (`$wp_filesystem->get_contents()` / `$wp_filesystem->put_contents()`). PCP flags `WordPress.WP.AlternativeFunctions.file_system_operations_fread`, `_fclose`, and `_fwrite` the same way it flags `fopen`; all four functions are violations
  - `slow_db_query_meta_key` — PCP warns whenever `meta_key` appears in a `WP_Query`/`get_posts()` call (`WordPress.DB.SlowDBQuery.slow_db_query_meta_key`). The fix is to ensure the column is indexed, or suppress with `// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- indexed on post_id and meta_key`
  - **`PreparedSQLPlaceholders.ReplacementsWrongNumber`** — `$wpdb->prepare()` received a different number of replacement values than there are `%d`/`%s`/`%f` placeholders in the query string. This is a real bug, not a style warning — fix the placeholder count, do not suppress
  - `wp_verify_nonce()` called with only `wp_unslash()` on the nonce value — must use `sanitize_text_field( wp_unslash( ... ) )` because `wp_verify_nonce()` is pluggable
  - `WP_CONTENT_DIR . '/subfolder/'` for writable storage — use `wp_upload_dir()['basedir'] . '/plugin-slug/'` for all persistent file storage outside the database
  - Logging a raw superglobal value before the `sanitize_*()` call on the same or a later line — WordPress.org flags this even with a `phpcs:ignore InputNotSanitized` comment; sanitize first, log after
  - Any unescaped intermediate variable in HTML output
  - Missing `wp_unslash()` on superglobals — even for integer casts, use `(int) wp_unslash( $_POST['field'] ?? 0 )`
  - **`$_SERVER` superglobals** (`HTTP_HOST`, `REQUEST_URI`, `SCRIPT_NAME`, etc.) require the same treatment as `$_POST`: validate the key exists (`?? ''`), `wp_unslash()`, then `sanitize_text_field()`. PCP fires `InputNotValidated`, `MissingUnslash`, and `InputNotSanitized` on every bare `$_SERVER[...]` access. **Preferred pattern:** avoid `$_SERVER` entirely for URL construction — use `home_url()`, `admin_url()`, `wp_parse_url( home_url(), PHP_URL_HOST )`, and `add_query_arg()` instead. These WordPress helpers are already slashed/sanitised and produce the correct value regardless of server config. Direct `$_SERVER` reads for URL building are almost always replaceable by a WordPress equivalent.
  - Text domain not matching WordPress.org slug (derived from plugin *name*, not folder)
  - readme.txt: >5 tags, >150-char short description
  - `printf()`/`sprintf()` with a placeholder in an i18n string — must have `/* translators: %s: description */` on the line immediately above; PCP flags `WordPress.WP.I18n.MissingTranslatorsComment` as an error on every missing comment
  - `__( 'Text', $td )` / `esc_html__( 'Text', $text_domain )` → `__( 'Text', 'plugin-slug' )` — text domain must be a **string literal** in every i18n call (`WordPress.WP.I18n.NonSingularStringLiteralDomain`); one Critical error per call — a class with a shared `$this->td` property and 25 translated strings generates 25 errors
  - `_e( 'Text', 'slug' )` / `_ex( 'Text', 'ctx', 'slug' )` → `esc_html_e( 'Text', 'slug' )` — `_e()` and `_ex()` output unescaped; replace with the escaping variants
  - `esc_url_raw( $url )` in output context → `esc_url( $url )` — `esc_url_raw()` is a sanitiser, not an output escaper
  - `json_encode( $data )` → `wp_json_encode( $data )` — 

…(truncated)
