WordPress has written coding standards for PHP, and a tool that enforces them: PHP_CodeSniffer (PHPCS) with the WordPress Coding Standards ruleset (WPCS). Formatting is the least of what it checks. WPCS flags unescaped output, missing nonce checks, unprepared SQL, untranslatable strings and unprefixed globals, which makes it a security reviewer that never gets tired. This lesson installs it in the events plugin, configures it, and fixes what it finds.
Installing with Composer
Install PHPCS and WPCS per project, as development dependencies, so every developer and the CI server use the same versions:
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev wp-coding-standards/wpcs
WPCS pulls in PHP_CodeSniffer itself, plus a Composer plugin that registers the installed standards with PHPCS. Composer asks you to trust plugins before they run, which is what the first command does. Check that the standards are registered:
vendor/bin/phpcs -i
The list should include WordPress, WordPress-Core, WordPress-Docs and WordPress-Extra.
Which ruleset?
| Standard | Covers |
|---|---|
WordPress-Core | Formatting and naming: tabs, spacing, braces, Yoda conditions, naming conventions |
WordPress-Docs | DocBlocks on functions, classes and files |
WordPress-Extra | Core plus best practices: escaping output, nonce verification, sanitised input, prepared SQL, prefixing, i18n |
WordPress | All three combined |
For a plugin, WordPress (or WordPress-Extra if you do not want the documentation rules yet) is the right choice. The security sniffs live in Extra, and they are the main reason to run the tool.
The project ruleset
PHPCS reads its configuration from a ruleset file in the project root. Name it phpcs.xml.dist and commit it; a developer can override it locally with an untracked phpcs.xml.
<?xml version="1.0"?>
<ruleset name="MyPlugin">
<description>Coding standards for My Plugin.</description>
<!-- What to check. -->
<file>.</file>
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>/build/*</exclude-pattern>
<!-- How to check. -->
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg name="parallel" value="8"/>
<arg value="sp"/>
<!-- The oldest WordPress version the plugin supports. -->
<config name="minimum_wp_version" value="6.5"/>
<rule ref="WordPress"/>
<!-- Every global name must start with one of these. -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="myplugin"/>
<element value="MyPlugin"/>
</property>
</properties>
</rule>
<!-- Every translatable string must use this text domain. -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="myplugin"/>
</property>
</properties>
</rule>
<!-- src/ follows PSR-4 file names, not class-*.php. -->
<rule ref="WordPress.Files.FileName">
<exclude-pattern>/src/*</exclude-pattern>
<exclude-pattern>/tests/*</exclude-pattern>
</rule>
</ruleset>
What each part does:
fileandexclude-patterncheck the whole plugin but skip third-party and generated code. You are not responsible forvendor/, and fixing it would be undone by the next install.arg value="sp"shows progress (p) and the sniff code for each message (s). Sniff codes are what you need to configure or suppress a rule, so always show them.minimum_wp_versiontells sniffs that warn about deprecated functions which WordPress version you support.prefixeslists your prefixes. The sniff then reports any global function, class, constant, hook name or global variable that does not start with one. Namespaced code counts as prefixed when the namespace starts with a listed prefix, soMyPlugin\Bookings\Repositorypasses.text_domainreports any__(),esc_html_e()and similar call that uses another text domain, or none. A wrong text domain means the string is never translated, and it is an easy mistake to make when code is copied between projects.WordPress.Files.FileNameexpects files named likeclass-repository.php. That conflicts with PSR-4 autoloading from lessons 16 and 17, which needsRepository.php. The exclusion switches the file name sniff off forsrc/andtests/only.
Running the checks
vendor/bin/phpcs
With no arguments, PHPCS uses the ruleset file. Check one file while you work:
vendor/bin/phpcs src/Bookings/Repository.php
Output looks like this:
FILE: src/Admin/SettingsPage.php
----------------------------------------------------------------------
FOUND 2 ERRORS AFFECTING 2 LINES
----------------------------------------------------------------------
42 | ERROR | [x] Expected 1 space after "!"; 0 found
| | (WordPress.WhiteSpace.OperatorSpacing.NoSpaceAfter)
57 | ERROR | [ ] All output should be run through an escaping function
| | (see the Security sections in the WordPress Developer
| | Handbooks), found '$settings'.
| | (WordPress.Security.EscapeOutput.OutputNotEscaped)
----------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
----------------------------------------------------------------------
Errors marked [x] are fixable automatically. The rest need a human.
Fixing issues automatically
PHP Code Beautifier and Fixer (PHPCBF) ships with PHPCS and fixes everything marked [x]:
vendor/bin/phpcbf
It rewrites whitespace, indentation, array syntax, Yoda conditions and similar. Commit before running it on an existing codebase, then review the diff, so formatting changes are separate from real changes in your history.
Add both commands as Composer scripts, so nobody needs to remember the paths:
{
"scripts": {
"lint": "phpcs",
"lint:fix": "phpcbf"
}
}
Now composer lint and composer lint:fix work on every machine.
Fixing issues by hand
The remaining issues are often the important ones. Fix the cause rather than silencing the message:
<?php
// Before: flagged by WordPress.Security.EscapeOutput.OutputNotEscaped
echo $settings['max_guests'];
// After: escaped for an HTML text context.
echo esc_html( (string) $settings['max_guests'] );
<?php
// Before: flagged by WordPress.Security.NonceVerification.Missing
// and WordPress.Security.ValidatedSanitizedInput.
$event_id = $_POST['event_id'];
// After: nonce checked, input unslashed and sanitised.
check_admin_referer( 'myplugin_cancel_booking' );
$event_id = isset( $_POST['event_id'] ) ? absint( wp_unslash( $_POST['event_id'] ) ) : 0;
Suppressing a warning responsibly
Sometimes the code is correct and the sniff cannot tell. uninstall.php is a clear case: it drops the plugin's own table, so there is no core API to use instead and nothing to cache. WPCS still reports a direct query and a schema change. Suppress the specific sniffs, on the specific line, and say why:
<?php
// myplugin/uninstall.php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange -- Removing the plugin's own table on uninstall.
$wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . 'myplugin_bookings' ) );
The database sniffs look for the $wpdb variable, so they may say nothing about queries made through a property such as $this->wpdb in the repository. Do not read silence as approval: that class still needs review, which is one more reason to keep all SQL in one place.
Rules for ignores:
- Always name the sniff code. A bare
// phpcs:ignoresilences every rule on the line, including security rules you did not mean to switch off. - Always add a reason after
--. Reviewers, and you in six months, need to know why the rule does not apply. - Prefer
phpcs:ignore(next line) overphpcs:disableandphpcs:enablearound a block, which is easy to leave too wide. - Never ignore
WordPress.DB.PreparedSQLorEscapeOutputto make a warning go away. If the sniff is wrong, the comment must explain exactly why the value is safe.
If a rule genuinely does not suit the whole project, change it once in phpcs.xml.dist, with a comment, rather than scattering ignores.
Adopting it on an existing plugin
Running WPCS on a plugin that has never used it can produce thousands of messages. Do not try to fix them in one go:
- Run
phpcbfand commit the formatting changes on their own. - Fix all
WordPress.SecurityandWordPress.DB.PreparedSQLerrors next. They are the ones that matter for users. - Work through the rest file by file, and require that every changed file passes.
Editors and CI
Most editors can run PHPCS on save using the project's ruleset, which catches issues while you type. Then add a CI step: run composer install and composer lint on every pull request and fail the build on errors. Teams often add phpcompatibility/phpcompatibility-wp too, which checks that your code runs on every PHP version you declare in Requires PHP.
Summary
- WPCS adds WordPress rules to PHP_CodeSniffer; install both per project with Composer and allow the installer plugin.
- Use the
WordPressorWordPress-Extrastandard for plugins: the security sniffs live in Extra. - Commit a
phpcs.xml.distthat excludesvendor/and build output, setsminimum_wp_version, yourprefixesand yourtext_domain, and relaxes the file name rule for PSR-4 folders. - Run
phpcsto check andphpcbfto fix formatting automatically; fix security findings by hand. - Ignore a rule only on one line, by sniff code, with a reason, and run the checks in CI on every pull request.