php-collective/code-sniffer
A PHP_CodeSniffer ruleset that follows PSR-2 and PSR-12 with many extra fixers on top, plus a stricter PhpCollectiveStrict standard. Install via Composer, add it to phpcs.xml, and run phpcs/phpcbf or composer scripts to check and auto-fix code style.
This is a PHP_CodeSniffer standard (squizlabs/php_codesniffer), not a PHP-CS-Fixer preset — so it is a sibling/alternative to Laravel Pint, not a plugin for it. Pint fixes formatting; this package additionally enforces docblocks, native type hints, declare(strict_types=1), namespace/use ordering, and a long list of opinionated "don't do this" rules.
Install and wire it up in three steps:
composer require --dev php-collective/code-sniffer
Project-root phpcs.xml — start narrow, do not point it at the whole repo on day one:
<?xml version="1.0"?>
<ruleset name="laravel-app">
<file>app/</file>
<file>tests/</file>
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg value="ps"/> <!-- progress + show sniff codes -->
<exclude-pattern>*/vendor/*</exclude-pattern>
<exclude-pattern>*/storage/*</exclude-pattern>
<exclude-pattern>*/bootstrap/cache/*</exclude-pattern>
<exclude-pattern>*/public/*</exclude-pattern>
<exclude-pattern>*/_ide_helper*.php</exclude-pattern>
<rule ref="vendor/php-collective/code-sniffer/PhpCollective/ruleset.xml"/>
</ruleset>
Then:
vendor/bin/phpcs -i # sanity: is the "PhpCollective" standard registered?
vendor/bin/phpcs app/Http --report=summary
vendor/bin/phpcs -s app/Http/Controllers/UserController.php # -s prints the exact sniff to exclude
Where to look first
docs/sniffs.md in the installed package — the full sniff inventory, grouped by upstream standard (Generic, PSR1/2/12, SlevomatCodingStandard, PhpCollective, PhpCollectiveStrict). Read this before writing a single exclusion; most "weird" rules are named here.PhpCollective/ruleset.xml and PhpCollectiveStrict/ruleset.xml — the actual source of truth. PhpCollectiveStrict is PhpCollective + 3 aggressive TypeHints.* sniffs. Diff the two files to see exactly what "strict" adds.First use case: run it read-only (phpcs, never phpcbf) against a single leaf directory that already has decent types — typically app/Http/Controllers or a app/Services folder. Triage the output into three buckets: (1) auto-fixable, (2) genuine bugs the sniff caught, (3) conventions your team disagrees with → exclude those sniffs explicitly. Do this before letting the fixer anywhere near the codebase.
Two-tier: lint gate + selective autofix. The pragmatic Laravel setup is to keep Pint as the only auto-formatter and use this package as a check-only gate for the semantic sniffs Pint doesn't cover (strict types, forbidden functions, docblock completeness, !== vs !=, exit, use-statement ordering). Two fixers rewriting the same lines creates an infinite "fix → reformat → fix" churn loop. Pick one fixer.
Composer scripts with the useful flags:
"scripts": {
"cs-check": "phpcs -nps --parallel=8 --cache=.phpcs-cache",
"cs-fix": "phpcbf -nps --parallel=8 --no-pager",
"cs-diff": "phpcs --report=diff app/"
}
-n hides warnings (warnings are usually the noisy, half-configured sniffs), -p progress, -s sniff names. --cache is essential on a mid-size Laravel app; add .phpcs-cache to .gitignore.
Layered rulesets per directory. tests/ and database/migrations/ almost always need different rules than app/. Scope exclusions inside the rule rather than globally:
<rule ref="vendor/php-collective/code-sniffer/PhpCollective/ruleset.xml">
<!-- anonymous-class migrations and factory closures fight these -->
<exclude name="PhpCollective.Commenting.DocBlockParam"/>
<exclude name="PhpCollective.PHP.DeclareStrictTypes"/>
</rule>
<rule ref="vendor/php-collective/code-sniffer/PhpCollective/ruleset.xml">
<exclude-pattern>tests/*</exclude-pattern>
<exclude-pattern>database/migrations/*</exclude-pattern>
</rule>
Ratchet, don't big-bang. PHPCS has no native baseline. Practical options, cheapest first:
<exclude-pattern> per-legacy-directory, then delete one line per sprint.// phpcs:ignore Sniff.Name -- legacy, tracked in PROJ-123 on the specific line (must be on the violating line, or the line above for multi-line constructs).CI: GitHub Actions with staabm/phpcs-cs2pr turns the report into inline PR annotations:
- run: vendor/bin/phpcs --report=checkstyle --report-file=phpcs.xml.out || true
- run: phpcs-cs2pr < phpcs.xml.out
GitLab wants --report=checkstyle as a codequality artifact. In pre-commit hooks, PHPCS has no --staged mode — pass the staged filenames explicitly from git diff --name-only --cached --diff-filter=ACM -- '*.php'.
Upgrade to PhpCollectiveStrict only after PhpCollective is clean. The three added TypeHints.* sniffs demand native parameter/property/return types on every method, including legacy controllers and Eloquent relations — a genuine refactor, not a formatting pass. Treat it as a separate project.
Extension point: custom project sniffs go in e.g. tools/cs/Project/Sniffs/Forbidden/DdSniff.php with a local ruleset.xml alongside it; register installed_paths (or add dealerdirect/phpcodesniffer-composer-installer) so PHPCS finds it. This is the right place for Laravel-specific bans (dd, dump, env() outside config, DB::raw).
phpcbf as a code-changing operation, not a formatter. 0.6.9 is a straight bug-fix release for three fixers that silently produced broken or wrong code in 0.6.8: PreferCastOverFunction turned first-class callables into parse errors (array_map(strval(...), $names) → array_map((string)(...), $names)), DocBlockParamAllowDefaultValue appended a bogus |array to narrowed array types (non-empty-array<...>, non-empty-list<...>, bare list), and DocBlockThrows wrote @throws \LocalException instead of @throws \App\LocalException for same-namespace classes. If you ran the fixer before 0.6.9, grep your diff for those three shapes — the first one leaves the file non-parseable, the other two leave subtly wrong docblocks that PHPStan/baseline diffing may not catch.DocBlockThrows now resolves qualified relative names — throw new Sub\NestedException() was previously skipped and is now reported. Expect new violations on untouched code after the bump even though you changed nothing. Re-run phpcs before assuming a config regression.--config-set is a trap. phpcs --config-set installed_paths ... writes into vendor/squizlabs/php_codesniffer/CodeSniffer.conf, which is not committed and is wiped by composer install. In CI it will silently fall back to PEAR and report the wrong errors — or "standard not found". Always pass the standard via phpcs.xml or --standard= explicitly. If you want the shortcut, add dealerdirect/phpcodesniffer-composer-installer instead.squizlabs/php_codesniffer ^3 and this package eventually wants ^4 (or vice versa), composer require fails or silently pins an old standard. Check composer why squizlabs/php_codesniffer before debugging phantom behaviour."php-collective/code-sniffer": "x.y.*" and let Renovate/Dependabot open an isolated bump PR so you can see exactly which sniffs turned red. Note that patches matter too — 0.6.9 adds findings via the DocBlockThrows behaviour change — so if your repo is large, treat a patch bump as worth its own PR rather than letting it hitch a ride on an unrelated change.pint then phpcbf will ping-pong. Choose one writer. If you keep Pint, run this package with fixers disabled via phpcs only.RequireTrailingCommaInCall and DisallowTrailingCommaInCall families, plus PhpCollective.PHP.DisallowTrailingCommaInSingleLine. If you cherry-pick sniffs by hand you can configure an impossible standard; if you just ref the whole ruleset, don't add your own contradictory one on top.PhpCollective.Commenting.DisallowArrayTypeHintSyntax and DocBlockParamArray push Type[]; if your codebase is PHPStan level 8 with array<string, Foo> generics, these will fight. Exclude the array-syntax sniffs and keep generics — static analysis wins over docblock aesthetics. If you do keep narrowed forms (non-empty-array<...>, non-empty-list<...>, list<...>), be aware they were only recognised as valid array coverage from 0.6.9 onward — older fixer runs may have appended a redundant |array.PhpCollective.PHP.DeclareStrictTypes touches every file. Preview with phpcbf --report=diff app/ before writing; on a large app, do app/Models first in its own commit so the diff is reviewable.PhpCollective.Testing.* (AssertPrimitives, ExpectException, Mock) match PHPUnit-style assertions. Pest's expect()->toThrow() and global test() helpers can trip PhpCollective.Namespaces.FunctionNamespace. Exclude the Testing and Namespaces groups for tests/ if you're on Pest.PhpCollective.PHP.Exit flags exit/die — including places where it's legitimate (console commands, queue workers, abort() alternatives). Exclude it deliberately or you'll get used to ignoring the whole report.Generic.PHP.ForbiddenFunctions, PhpCollective.PHP.DisallowFunctions and Internal.DisallowFunctions ship with their own lists; dd/dump/var_dump are probably not all in there by default. Set them explicitly via <property name="forbiddenFunctions" type="array">.foo(...)) is everywhere in modern Laravel (array_map($this->normalise(...), $rows), Str::of(...)->pipe(...)). It is now safe from PreferCastOverFunction, but any other custom sniff you add that pattern-matches "function call followed by parentheses" is a good candidate for the same bug class — check it against a callable before trusting it.Generic.Files.LineLength is a warning-level sniff with a default limit around 120 that rarely matches a team's actual wrapping policy. Either set lineLimit/absoluteLineLimit yourself or drop it and let Pint handle width — otherwise -n hides it and nobody notices.0 clean, 1 violations, 2 fixer failed, 3 processing/config error. Failing the build on 3 with the same message as 1 sends people hunting for style problems when the real issue is a bad phpcs.xml.phpcbf --report=diff first, then write. Some fixers are semantically safe (intval() → (int), Yoda removal) but a few rewrite expressions (isset($x) ? $x : $y → ??) and — as 0.6.9 demonstrates — some can emit invalid PHP. Run on a clean worktree and read git diff — never phpcbf as part of a commit hook without review.phpcbf may need two passes. Fixing one violation can create another (e.g. a docblock line now exceeds the length limit). Always follow phpcbf with phpcs in the same script and fail if it's still dirty._ide_helper.php, bootstrap/cache/*.php, compiled Blade views in storage/framework/views, and vendor/ will produce thousands of errors and dominate runtime; the exclude-pattern entries above are not optional.How can I help you explore Laravel packages today?