phpcsstandards/php_codesniffer
PHP_CodeSniffer is a PHP library that tokenizes PHP, JavaScript, and CSS files to detect violations of a defined coding standard. It includes phpcs for detecting violations and phpcbf for automatically fixing them, ensuring consistent, clean code across your projects.
PHP_CodeSniffer is a dev-only tool. Install it with Composer and never commit vendor/:
composer require --dev phpcsstandards/php_codesniffer
vendor/bin/phpcs -i # list installed coding standards
vendor/bin/phpcs --version
Two binaries ship in vendor/bin:
phpcs — detects violations (the linter).phpcbf — the fixer; automatically repairs whatever sniffs mark as fixable.First run against a single file, with the sniff code visible so you can act on the output:
vendor/bin/phpcs -s --standard=PSR12 src/Foo.php
-s prints the full source code (PSR12.Files.FileHeader.SpacingAfterBlock) instead of just the message. Without it you will struggle to exclude anything later.
Where to look first. Run vendor/bin/phpcs --generator=Text > ruleset-reference.txt to dump the exact rules your chosen standard enforces, and vendor/bin/phpcs --explain=PSR12.Files.FileHeader to read the docs for a specific sniff. These two commands answer 80% of "why is it complaining about this?" questions without opening the source.
First real use case. Create a phpcs.xml.dist at the repo root so nobody has to remember flags:
<?xml version="1.0"?>
<ruleset name="Acme">
<description>Acme coding standard</description>
<file>src</file>
<file>tests</file>
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/var/cache/*</exclude-pattern>
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg name="parallel" value="8"/>
<arg name="cache" value=".phpcs.cache"/>
<rule ref="PSR12"/>
</ruleset>
Then vendor/bin/phpcs with no arguments picks it up automatically, and so does every editor integration and CI job. Order of discovery is .phpcs.xml, phpcs.xml, .phpcs.xml.dist, phpcs.xml.dist — first match wins.
Add Composer scripts immediately, because that is how the rest of the team will actually run it:
"scripts": {
"cs": "phpcs",
"cs:fix": "phpcbf",
"cs:diff": "phpcs --report=diff"
}
Never let CI, the editor and local runs use different standards. Everything — extensions, parallelisation, cache path, sniffs — belongs in the ruleset. Layering is done with <rule ref> plus exclusions:
<rule ref="PSR12">
<!-- We use a different line length budget -->
<exclude name="Generic.Files.LineLength"/>
</rule>
<rule ref="Generic.Files.LineLength">
<properties>
<property name="lineLimit" value="120"/>
<property name="absoluteLineLimit" value="140"/>
</properties>
</rule>
<!-- Downgrade to warning instead of error -->
<rule ref="Squiz.Commenting.FunctionComment.Missing">
<severity>5</severity>
</rule>
<severity>0</severity> silently disables a sniff; <severity>5</severity> keeps the message but moves it below the error threshold, so phpcs -n (no warnings) passes. That is the cheapest way to phase in a strict rule on a legacy file.
Turning on PSR12 across 400 files produces a wall of noise nobody reads. Sequence it:
vendor/bin/phpcs --report=source — sorted counts per sniff. Fix the top three offenders first.vendor/bin/phpcbf once. Most whitespace, brace and ordering issues vanish in a single pass.<exclude-pattern>src/Legacy/*</exclude-pattern> for the parts you cannot touch yet, then delete lines from that list as you refactor.// phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter -- signature fixed by the interface
public function handle($event, $payload) { ... }
// phpcs:disable Squiz.Commenting.FunctionComment
// ... generated code ...
// phpcs:enable
Always attach a code (Squiz.Commenting...) and a -- reason. Untargeted phpcs:ignore hides future regressions on the same line. phpcs --ignore-annotations lets you audit what the annotations are currently masking.
phpcbf is a code transformer, so treat it like a formatter: fix, then diff, then verify.
vendor/bin/phpcs --report=diff src/ # preview what's fixable, no writes
vendor/bin/phpcbf src/ # apply
vendor/bin/phpcs -s src/ # confirm the remainder is non-fixable
Run phpcbf as a pre-commit step or an IDE-on-save action, and keep phpcs as the gate. Never let phpcbf run in CI against a checkout — it will either silently rewrite files or fail on a dirty tree.
A company standard is just a directory with a ruleset.xml and Sniffs/<Category>/<Name>Sniff.php:
vendor/acme/coding-standard/
├── ruleset.xml
└── Sniffs/Naming/NoDebugCallsSniff.php
namespace Acme\CodingStandard\Sniffs\Naming;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
final class NoDebugCallsSniff implements Sniff
{
public function register(): array
{
return [T_STRING];
}
public function process(File $phpcsFile, $stackPtr)
{
$tokens = $phpcsFile->getTokens();
if (strtolower($tokens[$stackPtr]['content']) !== 'var_dump') {
return;
}
$fix = $phpcsFile->addFixableError(
'var_dump() left in code', $stackPtr, 'Found'
);
if ($fix === true) {
$phpcsFile->fixer->beginChangeset();
$phpcsFile->fixer->replaceToken($stackPtr, 'print_r');
$phpcsFile->fixer->endChangeset();
}
}
}
Referenced as AcmeCodingStandard.Naming.NoDebugCalls.Found. Only call beginChangeset() when addFixableError() returned true, and always close the changeset — an unbalanced changeset corrupts the fixer's token map and produces bizarre diffs.
Register the standard via dealerdirect/phpcodesniffer-composer-installer (it wires installed_paths for you) or, inside the consuming ruleset:
<config name="installed_paths" value="../../acme/coding-standard"/>
Exit codes: 0 clean, 1 violations found, 2 fixer/processing failure, 3 configuration error. Anything non-zero should fail the build — but distinguish 3, because a broken ruleset in CI looks identical to "code is bad" in a one-line log.
GitHub Actions pattern that annotates the PR diff rather than dumping 2000 lines:
- run: vendor/bin/phpcs --report=checkstyle -q | cs2pr
Locally, point PhpStorm (Settings → PHP → Quality Tools → PHP_CodeSniffer) or the VS Code extension at vendor/bin/phpcs and the root phpcs.xml.dist. For editors that pipe content instead of reading from disk, use stdin mode so paths and exclusions still resolve:
cat src/Foo.php | vendor/bin/phpcs --stdin-path=src/Foo.php -
--config-set writes outside your repo. phpcs --config-set installed_paths ... modifies CodeSniffer.conf inside vendor/, so it is wiped by the next composer install. Use <config> in the ruleset or --runtime-set installed_paths ... on the command line instead.--cache keys on file mtime plus the standard's hash, but adding an external standard or editing an XML file does not always invalidate it. If results seem stale, delete the cache file — and add .phpcs.cache to .gitignore and to your CI cache key.phpcs:ignore in the codebase. Pin the PHPCS version in composer.lock, and re-audit annotations when you bump.phpcbf loop. It bails after 50 passes and leaves the region untouched; the symptom is a file that never converges. Isolate with phpcbf --sniffs=A,B and drop one of the rules.--parallel needs pcntl. It silently falls back to serial on Windows or a PHP build without it, so timings differ between dev machines and CI. It also does not play nicely with blame-based reports.<arg name="extensions"> will miss files. Blade, .module, .theme, .phtml and .inc are not all picked up by default — enumerate them explicitly or PHPCS will happily report "clean" on a directory it never scanned.--standard=PSR1,PSR2,PSR12 re-enables rules PSR12 deliberately removed. Reference only the leaf standard.Tokens utility changes mean in-house sniffs need porting. If you maintain a custom standard, budget for it before upgrading — and keep register() returning an array of token constants, which is the part that survives.--report=source for "what should we fix first", --report=summary for per-file triage, --report=diff before running phpcbf, --report=performance when a 5000-file run feels slow, --report=json when you want to build a dashboard. -q plus --report=checkstyle is the right default for CI logs.phpcs src/Http/Controllers/FooController.php returns in under a second; a full-repo run with --parallel=8 on a cold cache can take minutes and kills the edit-run loop.How can I help you explore Laravel packages today?