Coding Standards and Static Analysis

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WordPress has written coding standards for PHP, JavaScript, CSS and HTML, plus an accessibility standard, and a set of tools that check most of them automatically. This page is for plugin and theme developers and for reviewers: what the standards ask for, how the tools are installed and configured for the lumen-catalog plugin of the Lumen Press site, what they found on a deliberately careless admin screen and how each finding was fixed, where they overlap with Plugin Check, and how to run them in CI. PHPStan itself (levels, PHPDoc types, generics) is explained in Static Analysis and Refactoring.

The page was written against WordPress 7.1.3 (released 2026-10-06, examples tested; 7.1.4, the security release of 2026-10-28, is the one to run in production), PHP 7.4 minimum and 8.3+ recommended, WPCS 3.4.1, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and checked on 2026-10-28. Composer resolved PHP_CodeSniffer 3.13.6, PHPCompatibilityWP 2.1.8 (on PHPCompatibility 9.3.5), PHPStan 2.3.3, szepeviktor/phpstan-wordpress 2.0.4 and php-stubs/wordpress-stubs 7.1.2, run in the composer:2 and php:8.4-cli images (php:7.4-cli for the syntax check) on a copy of the plugin; Plugin Check 2.1.0 ran on a 7.1.3 site (wordpress:cli-2.12.0-php8.4, MariaDB 11.8). WordPress 7.2 (in beta, planned for 2026-12-08) changes nothing here.

The WordPress coding standards

The Coding Standards Handbook on developer.wordpress.org has five standards plus inline-documentation standards. They are mandatory for WordPress core; for plugins and themes they are "encouraged", but the parts about security, i18n and prefixes are what the Plugin Directory review and Plugin Check enforce anyway.

Standard Main rules Checked by

PHP

Tabs for indentation, spaces inside parentheses, braces on the same line, elseif, Yoda conditions ('lp_book' === $type), long array syntax array(), no short ternary ?:, single quotes when nothing is interpolated, lowercase snake_case for functions, variables and hooks, Capitalized_Words for classes, class-*.php file names, one class per file, declared visibility, no extract(), no @ error suppression, no shorthand PHP tags, capitalised SQL keywords and prepared queries.

WPCS (WordPress-Core, WordPress-Extra)

PHP inline documentation

A DocBlock on every file, class, function, hook and property, with @param, @return and @since; hook DocBlocks right above do_action()/apply_filters().

WPCS (WordPress-Docs), PHPStan for hook DocBlocks

JavaScript

Tabs, spaces inside parentheses and brackets, semicolons, single quotes, camelCase, const/let, ===. The page still describes JSHint; the actual tooling is ESLint with @wordpress/eslint-plugin plus Prettier (the WordPress fork).

wp-scripts lint-js, wp-scripts format

CSS

Tabs, one selector per line, lowercase properties and values, short lowercase hex colours (#fff), shorthand where possible, properties grouped by meaning (display and position, box model, colour and typography, others).

wp-scripts lint-style (Stylelint with @wordpress/stylelint-config)

HTML

Valid markup, lowercase tags and attributes, quoted attribute values, a space before the slash of self-closing tags (<br />), indentation that follows the logical structure, also when PHP is mixed in.

Not automated beyond what ESLint’s JSX rules and the W3C validator catch

Accessibility

WordPress commits to conform to WCAG 2.2 level A and AA; ATAG 2.0 and WAI-ARIA 1.1 are the other normative documents.

Partly: jsx-a11y rules in lint-js, Plugin Check’s accessibility category, axe-core in browser tests; the rest is manual review (see Accessibility)

The WordPress PHP style is not PSR-12 (see PSR Standards and Coding Style). A plugin may use PSR-12 instead, but then still runs the WPCS security, i18n and prefix sniffs. lumen-catalog follows the WordPress style except for PSR-4 file names.

The toolchain

Tool Version used Role

PHP_CodeSniffer (phpcs, phpcbf)

3.13.6

Runs the sniffs and fixes what is fixable. WPCS 3.4 requires 3.13.5 or later and does not support PHP_CodeSniffer 4 yet.

wp-coding-standards/wpcs

3.4.1

The WordPress rulesets and sniffs (security, i18n, prefixes, deprecated functions, style), with PHPCSUtils 1.2.3, PHPCSExtra 1.5.1 and dealerdirect/phpcodesniffer-composer-installer 1.2.1, which registers the standards.

phpcompatibility/phpcompatibility-wp

2.1.8

PHPCompatibility with exceptions for the polyfills WordPress ships.

phpstan/phpstan with szepeviktor/phpstan-wordpress and phpstan/extension-installer

2.3.3, 2.0.4, 1.4.3

Type-based static analysis with WordPress stubs, return types of core functions and hook DocBlock validation.

@wordpress/scripts

37.0.0

lint-js (ESLint 10, @wordpress/eslint-plugin 28.0.0), lint-style (Stylelint 17, @wordpress/stylelint-config 27.0.0), format (wp-prettier).

Installing the PHP tools with Composer

The development tools go into require-dev of the plugin’s composer.json, so the release build (composer install --no-dev) ships none of them. The file below extends the one in Plugin Fundamentals; the test packages are added in Testing.

{
	"name": "lumenpress/lumen-catalog",
	"type": "wordpress-plugin",
	"license": "GPL-2.0-or-later",
	"require": {
		"php": ">=7.4"
	},
	"require-dev": {
		"dealerdirect/phpcodesniffer-composer-installer": "^1.2",
		"phpcompatibility/phpcompatibility-wp": "^2.1",
		"phpstan/extension-installer": "^1.4",
		"phpstan/phpstan": "^2.3",
		"szepeviktor/phpstan-wordpress": "^2.0",
		"wp-coding-standards/wpcs": "^3.4.1"
	},
	"autoload": {
		"psr-4": {
			"Lumen\\Catalog\\": "src/"
		}
	},
	"config": {
		"platform": {
			"php": "7.4.33"
		},
		"sort-packages": true,
		"optimize-autoloader": true,
		"allow-plugins": {
			"dealerdirect/phpcodesniffer-composer-installer": true,
			"phpstan/extension-installer": true
		}
	},
	"scripts": {
		"lint": "phpstan analyse --memory-limit=1G",
		"format": "phpcbf",
		"format:check": "phpcs"
	}
}
  • The installer plugin registers the standards with PHP_CodeSniffer, so config.allow-plugins must allow it; vendor/bin/phpcs -i then lists PHPCompatibilityWP, WordPress, WordPress-Core, WordPress-Docs and WordPress-Extra among the installed standards.

  • config.platform.php makes Composer resolve versions that still run on PHP 7.4, the plugin’s floor; PHPStan also reads it as the PHP version to analyse for.

  • The committed composer.lock pins the exact versions listed above; the caret constraints only decide what composer update may move to. ^3.4.1 keeps WPCS at or above 3.4.1, a security release: before it, the WordPress.WP.EnqueuedResourceParameters sniff (part of WordPress-Extra and WordPress) could execute commands on the machine scanning untrusted code, for example a CI job linting a pull request from a fork.

  • The script names (lint for PHPStan, format for PHPCBF, format:check for PHPCS) are the ones the other PHP projects of this site use, so a CI pipeline runs the same commands everywhere. phpcbf exits with status 1 when it fixed something, so composer format looks like a failure after a successful fix; composer format:check confirms.

phpcs.xml.dist for lumen-catalog

PHP_CodeSniffer looks for .phpcs.xml, phpcs.xml, .phpcs.xml.dist and phpcs.xml.dist, in that order, in the current directory. The committed .dist file holds the project’s rules; a developer can override it locally with an untracked phpcs.xml. The skeleton in Plugin Fundamentals starts with a minimal phpcs.xml.dist; the full ruleset below replaces its contents (a leftover .phpcs.xml.dist next to it would win, as PHP_CodeSniffer reads the dotted one first).

<?xml version="1.0"?>
<ruleset name="Lumen Catalog">
	<description>WordPress Coding Standards for Lumen Catalog.</description>

	<file>.</file>

	<exclude-pattern>/vendor/*</exclude-pattern>
	<exclude-pattern>/node_modules/*</exclude-pattern>
	<exclude-pattern>/build/*</exclude-pattern>
	<exclude-pattern>/artifacts/*</exclude-pattern>

	<arg name="extensions" value="php"/>
	<arg name="basepath" value="."/>
	<arg name="parallel" value="8"/>
	<arg value="sp"/>

	<!-- Keep in sync with the Requires PHP and Requires at least headers. -->
	<config name="testVersion" value="7.4-"/>
	<config name="minimum_wp_version" value="6.9"/>

	<rule ref="WordPress-Extra"/>
	<rule ref="WordPress-Docs"/>
	<rule ref="PHPCompatibilityWP"/>

	<!-- From the full WordPress standard: unslash and sanitize every superglobal read. -->
	<rule ref="WordPress.Security.ValidatedSanitizedInput"/>

	<rule ref="WordPress.WP.I18n">
		<properties>
			<property name="text_domain" type="array">
				<element value="lumen-catalog"/>
			</property>
		</properties>
	</rule>

	<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
		<properties>
			<property name="prefixes" type="array">
				<element value="lumen_catalog"/>
				<element value="Lumen\Catalog"/>
			</property>
		</properties>
	</rule>

	<!-- Capabilities the plugin registers, so WordPress.WP.Capabilities does not report them as unknown. -->
	<rule ref="WordPress.WP.Capabilities">
		<properties>
			<property name="custom_capabilities" type="array">
				<element value="manage_lumen_catalog"/>
				<element value="manage_lp_terms"/>
				<element value="publish_lp_books"/>
			</property>
		</properties>
	</rule>

	<!-- PSR-4: class files are named after their class, not class-*.php. -->
	<rule ref="WordPress.Files.FileName">
		<exclude-pattern>/src/*</exclude-pattern>
		<exclude-pattern>/tests/*</exclude-pattern>
	</rule>
</ruleset>
Element Why

<file> and <exclude-pattern>

Scan the plugin, never Composer or npm output, built assets or Playwright artifacts. <arg name="extensions" value="php"/> keeps JavaScript and CSS out (their linters are below).

<arg>

Relative paths, eight files in parallel, sniff codes (needed for a precise ignore) and progress in the output.

testVersion

The lowest PHP version for PHPCompatibility: 7.4- means 7.4 and everything above.

minimum_wp_version

The Requires at least header. Sniffs for deprecated functions, classes, parameters and capabilities, and the %i placeholder check, use it. WPCS 3.4 defaults to 6.7 and knows deprecations up to WordPress 7.0.0.

WordPress-Extra and WordPress-Docs

WordPress-Core is the style; WordPress-Extra includes it and adds escaping, nonces, prefixes, deprecated code, capabilities and best practices; WordPress-Docs adds the DocBlock sniffs. The WordPress standard is all three plus four sniffs no subset includes: WordPress.DB.DirectDatabaseQuery, WordPress.DB.SlowDBQuery, WordPress.Security.ValidatedSanitizedInput and the I18nTextDomainFixer utility.

ValidatedSanitizedInput

Added on top of WordPress-Extra: without it, $book = $_GET['book']; passes with only a nonce warning. Plugin Check runs this sniff, so leaving it out only delays the finding. The two WordPress.DB sniffs stay out; custom tables need direct queries, and Plugin Check reports them as warnings anyway (see below).

text_domain

Every translation call must use lumen-catalog (see Internationalization).

prefixes

Global functions, classes, constants, variables and hook names fired by the plugin must start with lumen_catalog (or live in the Lumen\Catalog namespace). Option and cron names are not checked by the sniff and keep the lp_catalog_ prefix by convention.

custom_capabilities

The WordPress.WP.Capabilities sniff (WPCS 3.0) warns about any capability core does not define; without the property, the screen below got WordPress.WP.Capabilities.Unknown for manage_lumen_catalog and publish_lp_books. Every custom capability used literally in code goes in this list.

WordPress.Files.FileName exclusion

PSR-4 names (src/Admin/StockReport.php) instead of class-stock-report.php; without it every class file gets InvalidClassFileName.

PHPCS and PHPCBF on a real class

A first draft of a Books > Stock screen, listing the stock movements of one book from the {$wpdb->prefix}lp_stock table, was written in a hurry and booted from Plugin::boot():

<?php
namespace Lumen\Catalog\Admin;

class StockReport {

    public static function boot() {
        add_action( 'admin_menu', [ self::class, 'menu' ] );
    }

    public static function menu() {
        add_submenu_page( 'edit.php?post_type=lp_book', __( 'Stock history', 'lumen' ), __( 'Stock', 'lumen-catalog' ), 'manage_lumen_catalog', 'lp-stock', [ self::class, 'render' ] );
    }

    public static function render() {
        global $wpdb;
        $book = $_GET['book'];
        $rows = $wpdb->get_results( "SELECT changed_at, delta FROM {$wpdb->prefix}lp_stock WHERE book_id = $book" );
        echo '<h1>' . __( 'Stock history', 'lumen-catalog' ) . '</h1><table>';
        foreach ( $rows as $row ) {
            echo "<tr><td>$row->changed_at</td><td>$row->delta</td></tr>";
        }
        echo '</table>';
        if ( current_user_can( 'publish_lp_books' ) ) {
            echo '<p>' . esc_html( self::total( $rows ) ) . '</p>';
        }
        /**
         * Fires after the stock history table.
         */
        do_action( 'stock_report_after', $book );
    }

    public static function total( $rows ) {
        return array_sum( array_column( $rows, 'delta' ) );
    }

    public static function last_changed( int $book_id ): ?string {
        $post = get_post( $book_id );
        return $post?->post_modified;
    }
}

From the plugin folder, the tools run in containers:

docker run --rm -v "$PWD":/app -w /app composer:2 composer install
docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpcs --report=summary
docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpcbf
docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpcs --report=emacs -q

The summary reported 47 errors and 1 warning in src/Admin/StockReport.php, 32 of them fixable. PHPCBF fixed those 32 (spaces to tabs, [] to array()) and exited with status 1; the second run listed the 16 that need a person:

src/Admin/StockReport.php:1:1: error - Missing file doc comment (Squiz.Commenting.FileComment.Missing)
src/Admin/StockReport.php:4:1: error - Missing doc comment for class StockReport (Squiz.Commenting.ClassComment.Missing)
src/Admin/StockReport.php:11:78: error - Mismatched text domain. Expected 'lumen-catalog' but got 'lumen'. (WordPress.WP.I18n.TextDomainMismatch)
src/Admin/StockReport.php:16:17: warning - Processing form data without nonce verification. (WordPress.Security.NonceVerification.Recommended)
src/Admin/StockReport.php:16:17: error - Detected usage of a possibly undefined superglobal array index: $_GET['book']. Check that the array index exists before using it. (WordPress.Security.ValidatedSanitizedInput.InputNotValidated)
src/Admin/StockReport.php:16:17: error - $_GET['book'] not unslashed before sanitization. Use wp_unslash() or similar (WordPress.Security.ValidatedSanitizedInput.MissingUnslash)
src/Admin/StockReport.php:16:17: error - Detected usage of a non-sanitized input variable: $_GET['book'] (WordPress.Security.ValidatedSanitizedInput.InputNotSanitized)
src/Admin/StockReport.php:17:37: error - Use placeholders and $wpdb->prepare(); found interpolated variable $book at "SELECT changed_at, delta FROM {$wpdb->prefix}lp_stock WHERE book_id = $book" (WordPress.DB.PreparedSQL.InterpolatedNotPrepared)
src/Admin/StockReport.php:18:23: error - All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '__'. (WordPress.Security.EscapeOutput.OutputNotEscaped)
src/Admin/StockReport.php:20:18: error - All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '"<tr><td>$row->changed_at</td><td>$row->delta</td></tr>"'. (WordPress.Security.EscapeOutput.OutputNotEscaped)
src/Admin/StockReport.php:29:20: error - Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "stock_report_after". (WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound)

The list leaves out five Squiz.Commenting.FunctionComment.Missing errors, one per method. The three ValidatedSanitizedInput lines are the ones WordPress-Extra alone does not produce. The fixes, in the version below: DocBlocks everywhere, the lumen-catalog domain, the input read with isset() and absint() (an integer sanitiser, which also satisfies the unslash rule), the query through $wpdb->prepare() with %d, esc_html__() and esc_html() on output, the hook renamed to lumen_catalog_stock_report_after, and total() declared as total( array $rows ): int with a PHPDoc array shape. Escaping and preparing are explained in Security for Developers.

	/**
	 * Prints the stock movements of the book given in the "book" query argument.
	 */
	public static function render(): void {
		global $wpdb;

		// A read-only screen behind a capability check: no state changes, so no nonce.
		$book_id = isset( $_GET['book'] ) ? absint( $_GET['book'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$rows    = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
			$wpdb->prepare( "SELECT changed_at, delta FROM {$wpdb->prefix}lp_stock WHERE book_id = %d ORDER BY changed_at DESC", $book_id ),
			ARRAY_A
		);

		echo '<h1>' . esc_html__( 'Stock history', 'lumen-catalog' ) . '</h1><table>';
		foreach ( $rows as $row ) {
			printf( '<tr><td>%s</td><td>%s</td></tr>', esc_html( $row['changed_at'] ), esc_html( $row['delta'] ) );
		}
		echo '</table>';
		if ( current_user_can( 'publish_lp_books' ) ) {
			echo '<p>' . esc_html( self::total( $rows ) ) . '</p>';
		}

		/**
		 * Fires after the stock history table.
		 *
		 * @param string $book_id ID of the book shown.
		 */
		do_action( 'lumen_catalog_stock_report_after', $book_id );
	}

PHPCS then reported nothing. Each inline ignore names the sniff and says why, never a bare phpcs:ignore or a phpcs:disable block. The DirectDatabaseQuery ignore is there for Plugin Check, which runs that sniff (see below).

PHPCompatibilityWP and the PHP floor

PHPCompatibility reports syntax and functions that do not exist on the versions in testVersion; the WP flavour does not report functions WordPress polyfills (for example str_contains() since 5.9). The released PHPCompatibility 9.3.5 dates from 2019 and knows PHP up to 7.4, so with a 7.4 floor it has almost nothing left to find: the nullsafe operator ($post?->post_modified, PHP 8.0) in last_changed() above was not reported. PHPStan 2.3.3, which reads PHP 7.4.33 from config.platform.php, did not report it either. Only the PHP 7.4 interpreter did:

docker run --rm -v "$PWD":/app -w /app php:7.4-cli php -l src/Admin/StockReport.php
Parse error: syntax error, unexpected '->' (T_OBJECT_OPERATOR) in src/Admin/StockReport.php on line 38
Errors parsing src/Admin/StockReport.php

Under PHP 7.4 that parse error would be fatal on every request, because Plugin::boot() loads the class on plugins_loaded. A php -l pass on the oldest supported PHP (the CI job below has one) closes the gap until PHPCompatibility 10, still in alpha, is released. The method was removed; get_post_modified_time() does the job.

PHPStan with phpstan-wordpress

PHPStan does not know WordPress functions unless it is given their signatures. szepeviktor/phpstan-wordpress loads php-stubs/wordpress-stubs, adds return types that depend on arguments (get_post() with ARRAY_A, get_option() defaults, is_wp_error() narrowing), defines core constants and checks the DocBlock above apply_filters() and do_action() against the arguments passed. phpstan/extension-installer registers it, so the configuration names only the paths and the level:

# szepeviktor/phpstan-wordpress is registered by phpstan/extension-installer.
parameters:
    level: 6
    paths:
        - lumen-catalog.php
        - uninstall.php
        - src
    excludePaths:
        # render.php reads $attributes and $block, which WordPress injects.
        - src/blocks

The file is phpstan.neon.dist (NEON syntax, highlighted as YAML here); PHPStan reads an untracked phpstan.neon first, which leaves room for a local override. Level 6 is the house default: it requires parameter and return types (native or PHPDoc) and value types of arrays, which is where WordPress code is usually weakest. The tests path and the test stubs come with Testing. Block render.php files are excluded because their $attributes, $content and $block variables are injected by WordPress; PHPCS still checks them.

docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpstan analyse --memory-limit=1G --no-progress --error-format=raw

On the first draft, PHPStan reported five missing return and parameter types (missingType.return, missingType.parameter), the same places the DocBlock sniffs found. On the version that already passed PHPCS it found two real bugs that no sniff can see:

/app/src/Admin/StockReport.php:57:Parameter #1 $text of function esc_html expects string, int given.
/app/src/Admin/StockReport.php:65:@param string $book_id does not accept actual type of parameter: int<0, max>.

The first passes an integer to an escaping function, which works only because PHP coerces it; the fix formats the number for the reader’s locale: esc_html( number_format_i18n( self::total( $rows ) ) ). The second is the hook DocBlock check of phpstan-wordpress: the documented type of the action’s argument was wrong, and any developer hooking lumen_catalog_stock_report_after would have trusted it; the DocBlock now says @param int $book_id. With both fixed, PHPStan reported no errors.

Analysing only src/ gave Constant LUMEN_CATALOG_FILE not found.: the constant is defined in the main file, which therefore belongs in paths (or in bootstrapFiles). At level 10, global $wpdb is mixed and every use of it is an error (Cannot call method get_results() on mixed.).

Raising the level with a baseline

At level 9 the plugin had two errors: absint() receiving $_GET['book'] (mixed) and (int) applied to the mixed result of get_option() in the installer. A baseline records such existing errors so that only new ones fail:

docker run --rm -v "$PWD":/app -w /app php:8.4-cli vendor/bin/phpstan analyse --memory-limit=1G --level=9 --generate-baseline

It printed [OK] Baseline generated with 2 errors. and wrote phpstan-baseline.neon:

parameters:
	ignoreErrors:
		-
			message: '#^Parameter \#1 \$maybeint of function absint expects array\|bool\|float\|int\|resource\|string\|null, mixed given\.$#'
			identifier: argument.type
			count: 1
			path: src/Admin/StockReport.php

		-
			message: '#^Cannot cast mixed to int\.$#'
			identifier: cast.int
			count: 1
			path: src/Installer.php

Adding includes: [ phpstan-baseline.neon ] and level: 9 to phpstan.neon.dist made the run pass. A baselined error that disappears is reported as a stale entry (reportUnmatchedIgnoredErrors). A baseline suits legacy code; the house default stays at level 6 because higher levels mostly argue with the mixed returns of WordPress’s own API.

JavaScript and CSS: lint-js, lint-style and format

The block and admin code is checked by @wordpress/scripts, already a development dependency for the build (see Block Development Environment). The scripts in package.json:

{
	"scripts": {
		"lint:js": "wp-scripts lint-js src",
		"lint:style": "wp-scripts lint-style \"src/**/*.{css,scss}\"",
		"format": "wp-scripts format src"
	}
}

The default ESLint configuration does not know the plugin’s text domain, so a wrong domain in JavaScript passes. An eslint.config.cjs at the plugin root (ESLint 10 reads only flat configuration files; a leftover .eslintrc triggers a warning and is ignored) extends the default and turns on the check:

const defaultConfig = require( '@wordpress/scripts/config/eslint.config.cjs' );

module.exports = [
	...defaultConfig,
	{
		rules: {
			'@wordpress/i18n-text-domain': [
				'error',
				{ allowedTextDomain: 'lumen-catalog' },
			],
		},
	},
];

A careless edit.js for lumen/book-card (four-space indentation, an <img> without alt, a console.log, 'lumen' as text domain, a translated string with a trailing space) first produced three import/no-extraneous-dependencies errors: the @wordpress/* packages the code imports must be listed in dependencies, even though webpack leaves them to WordPress. With those listed and the configuration above, npm run lint:js reported 18 problems: 12 prettier/prettier formatting errors and these six (path shortened):

src/blocks/book-card/edit.js
   5:44  error    'setAttributes' is defined but never used                                                                  no-unused-vars
   8:9   error    Unexpected console statement                                                                               no-console
   9:8   warning  React Hook useEffect has a missing dependency: 'isbn'. Either include it or remove the dependency array    react-hooks/exhaustive-deps
  13:13  error    img elements must have an alt prop, either with meaningful text, or an empty string for decorative images  jsx-a11y/alt-text
  14:17  error    Invalid text domain 'lumen'                                                                                @wordpress/i18n-text-domain
  14:17  error    Translations should not contain flanking whitespace (whitespace)                                           @wordpress/i18n-no-flanking-whitespace

npm run format rewrote the indentation and spacing. The rest was fixed by hand: the unused setAttributes and the useEffect logging went, the cover became <img src={ coverUrl } alt="" /> (decorative, since the title is next to it), and the label became sprintf( __( 'ISBN: %s', 'lumen-catalog' ), isbn ) with a translators: comment. lint:js then passed.

lint-style reported 11 errors on a three-rule style.scss written on single lines with #DDD (brace newlines, @stylistic/color-hex-case, trailing semicolon, rule-empty-line-before), all fixed by --fix. It did not object to the same selector appearing twice; the bundled configuration does not enable no-duplicate-selectors.

wp-scripts format always runs Prettier with --write: it has no check mode, and format --check src/tmp.js wrote the file and formatted the whole project instead of the given path. CI therefore relies on the prettier/prettier rule inside lint-js, or runs format and fails on git diff --exit-code, as below.

Overlap with Plugin Check

Plugin Check (see Publishing to the Plugin Directory) bundles its own PHP_CodeSniffer and WPCS and ignores the plugin’s phpcs.xml.dist. Several of its checks are single WPCS sniffs run with the full WordPress standard:

Plugin Check check What it runs Covered by the lumen-catalog ruleset

late_escaping

WordPress.Security.EscapeOutput

Yes (WordPress-Extra)

i18n_usage

WordPress.WP.I18n with the plugin slug as text domain

Yes, with text_domain

prefixing

WordPress.NamingConventions.PrefixAllGlobals with prefixes it guesses from the code

Partly: the guess can be stricter than the ruleset

direct_db_queries

WordPress.DB.DirectDatabaseQuery

No; answered by phpcs:ignore where a direct query is intended

plugin_review_phpcs

Its own ruleset: prepared SQL, ValidatedSanitizedInput, nonces, development functions, alternative functions, deprecated code, discouraged functions and more

Mostly; ValidatedSanitizedInput is why the ruleset adds that sniff

Run on the fixed copy (wp plugin check lumen-catalog --checks=late_escaping,direct_db_queries,i18n_usage,prefixing,plugin_review_phpcs), it printed "Success: Checks complete. No errors found." Two findings on the way show the differences:

  • With the phpcs:ignore comments removed, direct_db_queries and plugin_review_phpcs reported WordPress.Security.NonceVerification.Recommended, WordPress.DB.DirectDatabaseQuery.DirectQuery and WordPress.DB.DirectDatabaseQuery.NoCaching as warnings: Plugin Check honours the same inline ignores.

  • An earlier ruleset also accepted lumen as a prefix, so the $lumen_query and $lumen_book variables of the lumen/latest-books render.php passed PHPCS. The prefixing check, which guesses the prefixes from the plugin’s code, did not accept lumen and reported both as PrefixAllGlobals.NonPrefixedVariableFound. They are now $lumen_catalog_query and $lumen_catalog_book, and the ruleset lists only lumen_catalog.

The remaining full-run errors were the expected ones for a development folder: application_detected for phpcs.xml.dist and phpstan.neon.dist, plugin_updater_detected for the private Update URI header and no_plugin_readme. They disappear in the release build, which leaves tool configuration out through .distignore. Plugin Check runs late, on a site, and has no PHPStan, PHPCompatibility, style or JavaScript checks.

Running everything in CI

The pipeline that builds and deploys the plugin is described in Deployment and CI/CD; its quality job runs the commands of this page. In GitHub Actions:

name: Quality

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  php:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        php: [ '7.4', '8.4' ]
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@2.40.0
        with:
          php-version: ${{ matrix.php }}
          tools: composer:v2
          coverage: none
      - run: composer install --no-interaction --no-progress
      - run: find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -print0 | xargs -0 -n1 php -l
      - run: composer format:check
      - run: composer lint -- --no-progress --error-format=github

  js:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint:js
      - run: npm run lint:style
      - run: npm run format && git diff --exit-code

actionlint 1.7.12 accepted the workflow and its PHP steps passed locally in Docker; it was not run on GitHub. The PHP 7.4 leg catches syntax newer than the floor; --error-format=github turns PHPStan errors into annotations on the diff. Pull requests from forks run the scanners on unreviewed code, so WPCS must stay at 3.4.1 or later and the job gets no secrets. Themes (lumen, lumen-classic) use the same files with "type": "wordpress-theme", their own text domain and prefix, and PHPStan paths functions.php, src and patterns; Theme Check is covered in Theme Accessibility and Releasing.

Common errors and pitfalls

Symptom Cause and fix

ERROR: Referenced sniff "WordPress-Extra" does not exist.

The installer plugin did not run: allow dealerdirect/phpcodesniffer-composer-installer in config.allow-plugins and run composer install again.

PHPCS ignores the new ruleset

A .phpcs.xml.dist or phpcs.xml exists as well and wins; keep one file.

Unsanitised $_GET passes PHPCS

WordPress-Extra lacks ValidatedSanitizedInput; add the sniff or use the WordPress standard.

PHPStan: Function add_action not found

The extension is not loaded: allow phpstan/extension-installer or include vendor/szepeviktor/phpstan-wordpress/extension.neon.

PHPStan: Variable $attributes might not be defined in render.php

Exclude src/blocks (or the built build/blocks) from PHPStan.

PHP 8 syntax reaches a PHP 7.4 site

Neither PHPCompatibility 9.3.5 nor PHPStan reported the nullsafe operator; lint with php -l on the floor version.

wp-scripts format --check rewrites files

format always writes; check with lint-js or git diff --exit-code.

Where older books and tutorials are outdated

  • Installing WPCS by cloning its repository and setting installed_paths by hand, or installing it globally, belongs to WPCS 2 and earlier; WPCS 3 (2023) supports only Composer and needs PHPCSUtils, PHPCSExtra and the installer plugin. The WordPress-VIP ruleset some tutorials reference was removed in WPCS 2.0.

  • Old // WPCS: XSS OK and // WPCS: input var ok annotations stopped working in WPCS 3; only phpcs:ignore with sniff codes is recognised.

  • The JavaScript standard page and many books still describe JSHint and .jshintrc. Block and admin code is linted with ESLint through wp-scripts lint-js; with @wordpress/scripts 37 (ESLint 10) the configuration is a flat eslint.config.* file.