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:123Description of violation
High — security or data integrity risk
file.php:78Description of violation
Medium — code quality, documentation, reuse
file.php:12Description of violation
Low — style, naming, minor standards
file.php:34Description 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.
# 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.phponly — 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:,VERSIONconstant, andreadme.txtStable tag:must always match CHANGELOG.mdupdated on every commit that changes behaviour
PCP compliance
No echoed
<script>or<style>tags anywhere — the top WordPress.org rejection reasonNo hidden files (filenames beginning with
.) in the plugin directory — WordPress.org automated scan rejects them withhidden_fileserror. Common culprits:.distignore,.gitignore,.DS_Store. Exclude all dot-files from the distribution zip.No
error_log(),var_dump(),print_r(), or baredie()in committed codeNo hardcoded URLs — use
plugin_dir_url()andplugin_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 reportstextdomain_mismatchandWordPress.WP.I18n.TextDomainMismatchon every i18n call if wrong. Always verify: slugify the plugin name and confirm it matchesText 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, notcloudscale). The automated pre-reviewer warns if the submitting account's username is absent.All URLs in
readme.txtmust 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 withcurl -sI <url> | head -1before 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 documentationinstead of// cloudflare.com/ips-v4).No
date()— usegmdate()(UTC) orwp_date()(localised). PCP flagsWordPress.DateTime.RestrictedFunctions.date_dateas an error on everydate()call.No
unlink()— usewp_delete_file(). PCP flagsWordPress.WP.AlternativeFunctions.unlink_unlink.No
rmdir()orreadfile()— use WP Filesystem API. PCP flags these as errors.wp_die()must receive escaped strings —wp_die( esc_html__( 'Forbidden', 'slug' ) )notwp_die( 'Forbidden' ). PCP flagsEscapeOutput.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'sEscapeOutput.OutputNotEscapedfires on any unescaped variable. No exceptions. Specific context rule: variables echoed insideonclick="..."attributes must useesc_js(), notesc_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 everysanitize_*()on superglobal input —sanitize_text_field( wp_unslash( $_POST['field'] ?? '' ) ). PCP flagsMissingUnslashotherwise.InputNotSanitized— PCP flags$_POSTarray values even when they are validated viaarray_map('intval', ...)orarray_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 triggersSquiz.PHP.DiscouragedFunctions— the suppress pattern applies to all values, not justset_time_limit(0).Schema queries (
SHOW CREATE TABLE,DESCRIBE) requireWordPress.DB.DirectDatabaseQuery.SchemaChangein addition toDirectQueryandNoCachingin the phpcs:ignore list.Multi-line
$wpdb->prepare()calls — when aphpcs:ignorecomment sits on the line above a multi-line statement, it only suppresses the first line. Usephpcs:disable/phpcs:enableblocks 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 throughwp_trim_words()(whitespace word split) and truncates anything past 2,500 words on import, surfacing the warning "TheDescriptionsection 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 (matcheswp_trim_words), notstr_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 toshell_exec().phpcs:ignoresuppression silences PHPCS/PCP but human reviewers will still reject it. Everycurl_execcall in your own code must be replaced withwp_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) wherewp_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 timeoutsand a comment, but be aware human reviewers may still flag it.cURL in drop-ins / mu-plugins / bundled
assets/*.phpis 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 forcurl_init()/curl_exec()becausewp_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 afunction_exists()guard that skips the request when the API is unavailable — never a cURL fallback:// 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.phpfiles 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 backupson each call.Direct DB queries (
$wpdb->query()etc.) flagged as discouraged — the completephpcs:ignorecomment requires three sniff codes, not two:// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- [reason]. OmittingPluginCheck.Security.DirectDB.UnescapedDBParameterleaves 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— useesc_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->prefixcontains 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 withabsint()/intval()/esc_sql()if any part could be external.
- Table names derived from
PluginCheck.Security.DirectDB.UnescapedDBParameterfires even when$wpdb->prepare()is used — Plugin Check's own sniff runs separately from PHPCS'sWordPress.DB.PreparedSQL.InterpolatedNotPrepared. A query can pass$wpdb->prepare()correctly (all user values parameterised) and still get flagged forUnescapedDBParameteron 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 whencheck_ajax_referer(),wp_verify_nonce(), orcheck_admin_referer()is called directly in the same function scope as the$_POST/$_GET/$_FILESaccess. A helper wrapper (e.g.ajax_check(),cs_verify_nonce()) that callscheck_ajax_referer()internally is not traced — every$_POSTread below it is still flagged. Fix: replace every helper call site with a directcheck_ajax_referer()call. Addingphpcs:disable NonceVerification.Missingis 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.mdhas 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 ishidden_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 withunzip -l plugin.zip | grep '/\.'before submitting.Echoed
<script>or<style>tags — the single most common WordPress.org rejection. Grep the entire codebase for<scriptand<stylebefore submitting. Every hit is a violation. Usewp_enqueue_script(),wp_add_inline_script(),wp_enqueue_style(), andwp_add_inline_style()exclusively. Seereferences/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. Seereferences/pcp-checklist.mdWordPress.org submission section.Global asset enqueue — PCP flags CSS/JS enqueued on every page. Always gate on
$hookin 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()— usewp_die()in HTTP contexts. Baredie()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 whereTested up tois 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 raiseTested up to, never lower it.Broken buttons after
onclickrefactor — when removing inlineonclickattributes 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 asquerySelector('[onclick="fnName()"]')— this selector worked while the attribute was present but returnsnullonce theonclickis 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 PCPonclickremoval, verify that (a) the button has anid, and (b)addEventListeneris attached via thatid. Never usequerySelector('[onclick=...]')as a binding selector — it is inherently self-defeating. Seereferences/pcp-checklist.md§onclick refactor checklist.NonceVerification.Missingvia helper delegation — PHPCS and the WordPress.org Plugin Check only recognise nonce verification whencheck_ajax_referer(),wp_verify_nonce(), orcheck_admin_referer()is called directly in the same handler scope. A shared helper (e.g.ajax_check(),cs_verify_nonce()) that wrapscheck_ajax_referer()internally is invisible to the sniff — every$_POST/$_GET/$_FILESaccess below the helper call is flagged, andphpcs:disable NonceVerification.Missingdoes not satisfy Plugin Check. Fix: replace every helper call site with a directcheck_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 inwp_ajax_actions and confirmingcheck_ajax_referer()appears directly in the same closure/function body. Seereferences/security.md§Delegated nonce verification for the full pattern.Unhandled async function rejections (silent JS failures) —
asyncfunctions called fromonclickattributes return a Promise. If that Promise rejects (due to any runtime error — including calling.styleon a nullgetElementByIdresult, 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 unlessconsole.error()is explicitly called in acatchblock. The symptom is a button that appears to work but produces no result. Audit rule: everyasyncfunction must wrap its entire body intry { ... } catch(err) { console.error(...); /* show user message */ }. Loops that call async functions should re-enable disabled buttons infinally {}. AllgetElementById()results must be null-checked before property access. Seereferences/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 needsesc_attr(); ordate()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()(anydate()call, regardless of context)mt_rand()→wp_rand()— PCP flagsWordPress.WP.AlternativeFunctions.rand_mt_randas an errorparse_url()→wp_parse_url()— PCP flagsWordPress.WP.AlternativeFunctions.parse_url_parse_urlas an error on every native call; always usewp_parse_url()insteadunlink()→wp_delete_file()rmdir()/readfile()→ WP Filesystemwp_die('string')→wp_die( esc_html__( 'string', 'slug' ) )exec()/shell_exec()/system()/passthru()→ must be removed entirely — addingphpcs:ignoreis not a fix; WordPress.org reviewers reject the plugin outright regardless of whetherescapeshellarg()is usedfile_put_contents()/file_get_contents()without phpcs:ignore —WordPress.WP.AlternativeFunctions.file_system_operations_*fopen()on remote URLs — usewp_remote_get()/wp_remote_post(); many hosts block PHP stream wrappers for remote accessfread()/fclose()/fwrite()→ WP Filesystem API ($wp_filesystem->get_contents()/$wp_filesystem->put_contents()). PCP flagsWordPress.WP.AlternativeFunctions.file_system_operations_fread,_fclose, and_fwritethe same way it flagsfopen; all four functions are violationsslow_db_query_meta_key— PCP warns whenevermeta_keyappears in aWP_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_keyPreparedSQLPlaceholders.ReplacementsWrongNumber—$wpdb->prepare()received a different number of replacement values than there are%d/%s/%fplaceholders in the query string. This is a real bug, not a style warning — fix the placeholder count, do not suppresswp_verify_nonce()called with onlywp_unslash()on the nonce value — must usesanitize_text_field( wp_unslash( ... ) )becausewp_verify_nonce()is pluggableWP_CONTENT_DIR . '/subfolder/'for writable storage — usewp_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 aphpcs:ignore InputNotSanitizedcomment; 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 ) $_SERVERsuperglobals (HTTP_HOST,REQUEST_URI,SCRIPT_NAME, etc.) require the same treatment as$_POST: validate the key exists (?? ''),wp_unslash(), thensanitize_text_field(). PCP firesInputNotValidated,MissingUnslash, andInputNotSanitizedon every bare$_SERVER[...]access. Preferred pattern: avoid$_SERVERentirely for URL construction — usehome_url(),admin_url(),wp_parse_url( home_url(), PHP_URL_HOST ), andadd_query_arg()instead. These WordPress helpers are already slashed/sanitised and produce the correct value regardless of server config. Direct$_SERVERreads 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 flagsWordPress.WP.I18n.MissingTranslatorsCommentas 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->tdproperty 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 variantsesc_url_raw( $url )in output context →esc_url( $url )—esc_url_raw()is a sanitiser, not an output escaperjson_encode( $data )→wp_json_encode( $data )—
…(truncated)