phpcsstandards/phpcsutils
PHPCSUtils is a suite of utility functions for PHP_CodeSniffer, giving standard authors reliable, backward-compatible helpers for token handling, sniff testing, and cross-version support. Integrates via Composer or manually; PHP 5.4 to 8.5.
PHPCSUtils is not an application library — it is a toolkit for people who write PHP_CodeSniffer sniffs. In a Laravel codebase the realistic entry point is: you already run phpcs (or are adding it next to Pint/PHPStan), you have opinions that no off-the-shelf standard encodes (e.g. "no env() outside config/", "no dd()/dump() committed", "queued jobs must declare $tries"), and you are going to write a small in-house standard. PHPCSUtils removes ~80% of the token-walking boilerplate from that job.
Install (dev only):
composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true
composer require --dev phpcsstandards/phpcsutils:"^1.0"
composer require --dev squizlabs/php_codesniffer:"^3.13" # or phpcsstandards/php_codesniffer:^4.0
The package ships the Dealerdirect Composer PHPCS plugin, so once installed it registers itself and your standard with PHPCS automatically. You do not add --config-set installed_paths entries for Composer installs.
Where to look first, in this order:
Arrays, FunctionDeclarations, PassedParameters, Variables, Conditions, Context, Lists, Namespaces, ObjectDeclarations, Operators, Scopes, TextStrings, UseStatements, Visibility).PHPCSUtils\Tokens\Collections — the token groups (Collections::arrayOpenTokensBC(), Collections::functionCallTokens(), …). These replace the long T_* arrays you would otherwise copy-paste.PHPCSUtils\AbstractSniffs\AbstractArrayDeclarationSniff — if your first rule touches arrays/config files, start here.Laravel.Env.NoEnvOutsideConfig sniff. It is small, obviously valuable, and exercises FunctionDeclarations::getName(), Context::inConfigFile()-style path checks, and File::getFilename().Skeleton standard layout (PHPCS autoloading is convention-based, so paths and namespaces must match exactly):
tools/phpcs/LaravelStandards/
├── ruleset.xml
└── Laravel/
└── Sniffs/
└── Env/
└── NoEnvOutsideConfigSniff.php
<!-- ruleset.xml -->
<?xml version="1.0"?>
<ruleset name="LaravelInternal">
<rule ref="PHPCSUtils"/> <!-- friendly error if PHPCSUtils is missing -->
<rule ref="Laravel.Env.NoEnvOutsideConfig"/>
</ruleset>
<?php
namespace LaravelStandards\Laravel\Sniffs\Env;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
use PHPCSUtils\Utils\FunctionDeclarations;
final class NoEnvOutsideConfigSniff implements Sniff
{
public function register(): array
{
return [T_STRING];
}
public function process(File $phpcsFile, $stackPtr): void
{
if (FunctionDeclarations::getName($phpcsFile, $stackPtr) !== 'env') {
return;
}
// ... scope check, then:
$phpcsFile->addError('Call config() and read from config instead of env().', $stackPtr, 'Found');
}
}
vendor/bin/phpcs --standard=tools/phpcs/LaravelStandards/ruleset.xml app/ -s
1. Prefer the abstract sniffs over hand-rolled token walking. AbstractArrayDeclarationSniff gives you processArray(File $phpcsFile, int $stackPtr) plus helpers like getArrayItems(), getActualArrayKey(), getDoubleArrowPtr(), isShortArrayOrList(). Sniffs over config/*.php become a dozen lines. Similarly, extend PHPCSUtils\AbstractSniffs\AbstractFunctionCallParameterSniff when the rule is about a call's arguments — you get processParameters()/processParameter() callbacks and the open/close parenthesis bookkeeping for free.
2. Reach for PassedParameters and FunctionDeclarations before raw tokens. Almost every Laravel-flavoured rule looks like "find call X, inspect argument N". That is:
use PHPCSUtils\Utils\PassedParameters;
use PHPCSUtils\Utils\FunctionDeclarations;
$name = FunctionDeclarations::getName($phpcsFile, $ptr);
$params = PassedParameters::getParameters($phpcsFile, $ptr);
$arg2 = PassedParameters::getParameterFromStack($params, 2, 'options');
These correctly handle nested parentheses, arrays, closures, and named arguments — the things that make naive findNext(T_COMMA) implementations wrong.
3. Use Collections for token sets instead of your own arrays. Collections::arrayOpenTokensBC(), Collections::functionCallTokens(), Collections::parameterTypeTokens() etc. are maintained across PHPCS 3.x/4.x and PHP version differences, so you do not have to re-audit your sniff when PHP 8.5 adds tokens.
4. Centralise "which file am I in" logic. Laravel rules are usually path-sensitive (app/ yes, config/ no, tests/ sometimes). Wrap File::getFilename(), File::getFilenameWithoutExtension() and Common::getDeclareScope() checks in a tiny LaravelStandards\Util\Paths helper so every sniff applies the same policy. Do not scatter strpos($file->getFilename(), 'config/') everywhere.
5. Pair with a fixer where it is safe, and only there. PHPCSUtils has no fixer helpers — you still write $phpcsFile->fixer->beginChangeset() / replaceToken() / endChangeset() and return the fix count from process(). Auto-fix imports ordering and whitespace; report-only for env() and dd() (a wrong autofix in a Laravel app is worse than a warning).
6. Unit-test the sniffs. Two mechanisms, do not mix them up:
AbstractSniffUnitTest, with *.inc and *.fixed fixture files. This is what actually validates your rule.PHPCSUtils\TestUtils\UtilityMethodTestCase. This requires the extra autoloader:<!-- phpunit.xml -->
<php>
<env name="PHPCSUtils_DIR" value="/path/to/PHPCSUtils"/>
</php>
// tests/bootstrap.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../vendor/phpcsstandards/phpcsutils/phpcsutils-autoload.php';
7. Wire it into the Laravel dev loop, not just CI.
"scripts": {
"lint:sniffs": "phpcs --standard=tools/phpcs/LaravelStandards/ruleset.xml app config routes database",
"pint": "pint",
"check": ["@pint", "@lint:sniffs", "phpstan", "test"]
}
Pint handles formatting (PHP-CS-Fixer under the hood); PHPCS handles your semantic rules. They do not conflict because they cover different concerns — but keep Pint's preset from rewriting files in the same CI step in a way that masks PHPCS failures.
8. Escape hatches in application code. Every sniff you add should have a documented ignore comment form, and you should use -s locally so developers can see the sniff code to write:
// phpcs:ignore Laravel.Env.NoEnvOutsideConfig.Found -- required for container bootstrapping
$key = env('APP_KEY');
9. Debugging a sniff. phpcs -vvv shows tokenising/proces decisions; phpcs --report=source shows which sniffs fire most; phpcs --sniffs=Vendor.Standard.Cat.Name isolates your rule. If a sniff fires zero times, first check the class/file naming (...Sniffs\Category\NameSniff ↔ Vendor.Standard.Category.Name) before suspecting PHPCSUtils.
squizlabs/php_codesniffer in an older monorepo will silently fail to load PHPCSUtils classes. Bump PHPCS first.composer config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true. Without it, the standard file loads but PHPCSUtils\... classes never resolve. Adding <rule ref="PHPCSUtils"/> to your ruleset converts the fatal into ERROR: the "PHPCSUtils" coding standard is not installed.phpcs --config-set installed_paths overwrites, so always read phpcs --config-show first and pass a comma-separated list. Prefer --runtime-set installed_paths or a mounted CodeSniffer.conf in CI images to avoid mutating a shared config.<Standard>/<Category>/Sniffs/<Name>Sniff.php with namespace <Standard>\<Category>\Sniffs. A typo here produces "referenced sniff does not exist", which looks like a PHPCSUtils problem but isn't.PSR12/Squiz.*, those keep behaving exactly as the installed PHPCS behaves. Only your own sniffs get the back-compat layer via PHPCSUtils\BackCompat\Helper::getVersion() and the BC utility wrappers.--extensions=php and exclude resources/views/**/*.blade.php, public/, storage/.vendor/, storage/, bootstrap/cache/. PHPCS scanning vendor is the single biggest cause of "PHPCS is too slow" complaints; it also produces false positives from third-party code.--cache masks sniff edits. While iterating on a sniff, run with --no-cache (or delete the cache file); otherwise PHPCS skips files that hash-match and you'll conclude your new rule is broken. In CI, use a cache key that includes the sniff source hash.$stackPtr positions shift. Re-fetch with $phpcsFile->findNext() after replaceToken(), or collect all targets first and let a single changeset handle them. Also return the affected token count from process() so PHPCS's loop accounting stays correct.PassedParameters::getParameters() and scope utilities are cheap per-call but not free. Register the narrowest token set you can (T_STRING + a name check beats registering 40 token types), and memoise per-file results in a private array keyed by file + token when you analyse the same construct repeatedly.PHPCSUtils\Tokens\Collections-style constants in your own standard's Tokens class; (4) only fork PHPCSUtils if you are maintaining a public standard and need to support a PHPCS version the package hasn't caught up with yet."require-dev": { "phpcsstandards/phpcsutils": "^1.0" } — never let it land in production autoload. If you publish your internal standard as a package, ^1.0 is the correct constraint for consumers of your standard too, since the release cadence is active and 1.x is documented as the supported line.How can I help you explore Laravel packages today?