pekral/phpcs-rules
pekral/phpcs-rules provides custom PHP_CodeSniffer rules and coding-standard configuration for PHP projects, helping teams enforce consistent style, catch common issues, and integrate automated checks into development and CI workflows.
pekral/phpcs-rules is a shared coding-standard package: you add it as a dev dependency and ref its ruleset from your project's phpcs.xml, so every project in your fleet flags the same things. It pulls in slevomat/coding-standard (the release notes' sniff names are Slevomat's) plus whatever else the ruleset enables.
1. Check your PHP version first. 0.5.0 requires php: ^8.5. Composer will simply refuse to install it on 8.4:
composer require --dev pekral/phpcs-rules:^0.5 # PHP >= 8.5 only
composer require --dev pekral/phpcs-rules:^0.4 # stay here on PHP 8.4
Pin the constraint deliberately (^0.4 / ^0.5) rather than *, because the ruleset itself changes behaviour between minors — see Gotchas.
2. Wire it into a phpcs.xml at the project root. Look at vendor/pekral/phpcs-rules/ first — open the shipped ruleset.xml and note its <ruleset name="..."> value, then reference either that name or the file path:
<?xml version="1.0"?>
<ruleset name="Project">
<description>Project standard, layered on pekral/phpcs-rules.</description>
<file>app</file>
<file>config</file>
<file>database</file>
<file>routes</file>
<file>tests</file>
<rule ref="vendor/pekral/phpcs-rules/ruleset.xml"/>
</ruleset>
3. First run / first use case — triage an existing Laravel app:
vendor/bin/phpcs -i # confirm standards are registered
vendor/bin/phpcs --report=source # which sniffs are noisy, ranked
vendor/bin/phpcs --report=summary # per-file counts
vendor/bin/phpcbf # auto-fix the mechanical stuff
--report=source is the single most useful first command: it tells you whether you're looking at 40 fixable whitespace nits or a handful of opinionated sniffs worth discussing with the team before you reformat the whole repo. Commit the phpcbf output as a standalone "style only" commit so review stays sane.
Where to look first: vendor/pekral/phpcs-rules/ruleset.xml (what's actually enabled), the package composer.json (the php constraint and the slevomat/coding-standard version it pins), and the changelog for ruleset-level changes — those are behavioural even when the version bump is minor.
Composer scripts as the single entry point. Don't let people memorise flags:
{
"scripts": {
"lint": "phpcs",
"lint:fix": "phpcbf",
"check": ["@lint", "@phpstan", "@test"]
}
}
phpcs with no arguments picks up phpcs.xml automatically, so composer lint is the whole interface. In CI, composer check mirrors what a developer runs locally.
Layered ruleset, not a fork. Treat the package as the base layer and add project-specific deltas after the ref. Per-directory overrides beat global exclusions:
<rule ref="vendor/pekral/phpcs-rules/ruleset.xml"/>
<!-- generated / third-party -->
<exclude-pattern>*/bootstrap/cache/*</exclude-pattern>
<exclude-pattern>*/_ide_helper*.php</exclude-pattern>
<exclude-pattern>*/storage/*</exclude-pattern>
<!-- relax one rule for tests only -->
<rule ref="SlevomatCodingStandard.Functions.StaticClosure">
<exclude-pattern>tests/*</exclude-pattern>
</rule>
<!-- relax one rule for migrations only -->
<rule ref="Generic.Files.LineLength">
<exclude-pattern>database/migrations/*</exclude-pattern>
</rule>
Get sniff codes with vendor/bin/phpcs -s (shows codes on failures) or vendor/bin/phpcs -e (lists everything in the standard). Never guess a sniff name.
Incremental adoption on a legacy codebase. PHPCS has no native baseline. Two workable patterns:
<file> one at a time (app/Http/Controllers, then app/Models, …) and let the rest stay unlinted.phpcs --report=json, extract the source keys, and emit <exclude name="..."> entries; commit the generated file and shrink it over time.Pre-commit / on-save. Run PHPCS only on staged files, and run phpcbf on save for the safe sniffs only — full phpcbf can rewrite more than you expect:
vendor/bin/phpcbf --sniffs=Generic.WhiteSpace.DisallowTabIndent,Generic.PHP.UpperCaseConstant,SlevomatCodingStandard.Namespaces.AlphabeticallySortedUses <files>
In your editor set the standard to phpcs.xml and the executable to vendor/bin/phpcs (never a global install — version drift causes phantom diffs).
CI job. A single job on PHP 8.5 with --parallel and the cache, plus human-readable annotation output:
- uses: shivammathur/setup-php@v2
with: { php-version: '8.5', coverage: none }
- run: composer install --prefer-dist --no-interaction
- run: vendor/bin/phpcs --parallel=4 --cache --report=checkstyle --report-file=phpcs.xml.out
- if: always()
run: vendor/bin/cs2pr < phpcs.xml.out
If your application still supports 8.4 at runtime but your dev toolchain is 8.5, keep tests on a matrix and run phpcs in the 8.5 leg only — or set config.platform.php to 8.5.0 so Composer resolves the dev dependency while your runtime constraint stays ^8.4. That works because PHPCS parses code rather than executing it, but treat it as an escape hatch: the package's own source must still parse on the interpreter that loads it.
Keep the fleet aligned. If the same package also ships PHPStan/Rector config, ref those from your phpstan.neon/rector.php rather than hand-maintaining duplicates. One upgrade then moves lint, static analysis and refactoring rules together — which is the whole point of a shared ruleset.
Upgrade ritual. Because ruleset edits ship as minor versions:
composer update pekral/phpcs-rules --with-dependencies
vendor/bin/phpcs --report=summary > /tmp/after.txt # compare against before.txt
vendor/bin/phpcbf
Do the update in its own PR, isolated from feature work.
Ruleset changes are silent breaking changes. 0.5.0 re-enables SlevomatCodingStandard.Functions.StaticClosure outside tests. Nothing in the version number screams at you, but code that was clean on 0.4.8 will fail after the bump. Always diff lint counts across a ruleset upgrade before merging anything else.
Static closures can't touch $this. That's the real cost of re-enabled StaticClosure: a closure inside a class that reads $this (or self::, or a property) must now be refactored to take its dependencies via use (...)/parameters, or the sniff has to be excluded for that file or directory:
<rule ref="SlevomatCodingStandard.Functions.StaticClosure">
<exclude-pattern>app/Support/Legacy/*</exclude-pattern>
</rule>
Don't sprinkle // phpcs:ignore — it doesn't stick to closures reliably and hides future violations. Prefer a per-file // phpcs:disable / // phpcs:enable pair around the specific block.
"Outside tests" is path-based. The exemption depends on how the ruleset identifies test paths. If your specs live somewhere unconventional (spec/, Modules/*/Tests, Pest files beside app/), the sniff may still fire there. Verify with vendor/bin/phpcs tests/SomeTest.php -s and add your own exclude-pattern if needed.
ReadonlyClass is now excluded. That means classes no longer get flagged for missing readonly. If your team liked enforcing it, re-add it explicitly:
<rule ref="SlevomatCodingStandard.Classes.ReadonlyClass"/>
Conversely, if you were fighting it, you can now drop readonly from classes where immutability never fit — the standard won't complain either way.
PHP 8.5 is a hard floor, not a suggestion. On 8.4 you get a Composer platform error, not a warning. Pin ^0.4 explicitly — a bare pekral/phpcs-rules constraint with platform.php: 8.5.0 in config will happily resolve 0.5.x and then the tool won't run on your 8.4 CI image.
The upstream CI change has a blast radius. 0.5.0 collapsed the 8.4/8.5 matrix into a single 8.5 worker. If you're on the 0.4.x line you're on a branch that is now tested against 8.5 only in the sense that it isn't — nothing is validated for that combination going forward. Plan the PHP upgrade rather than sitting on 0.4 long-term.
Warnings fail the build. PHPCS exits non-zero on warnings by default. If you only want errors to gate CI, --runtime-set ignore_warnings_on_exit 1 — but do that consciously, or you'll ship a ruleset whose warnings nobody ever reads.
phpcbf is not safe to run blind. Some fixers reorder use statements and rewrite constant case across files; running it over a dirty working tree makes review impossible. Run it on a clean tree, commit alone, and scope it with --sniffs on save-hooks.
Slevomat version conflicts. If you also require slevomat/coding-standard directly at a different constraint, Composer will fight the package's pin. Let the ruleset own the dependency unless you have a specific reason; if you must override, align to the version the package declares and expect to re-check on every ruleset bump.
Standards not registering. vendor/bin/phpcs -i should list the ruleset. If it doesn't, the dealerdirect/phpcodesniffer-composer-installer plugin is probably blocked by allow-plugins — allow it, or fall back to referencing the ruleset.xml path directly (which is the more robust option anyway).
Speed on big codebases. --parallel=$(nproc) and --cache (with the cache directory keyed on composer.lock in CI) turn a multi-minute lint into seconds. Watch memory with high parallelism; --parallel=4 is usually the sweet spot.
Debugging a confusing failure. vendor/bin/phpcs -s <file> prints the sniff code, -vv explains why a file was skipped, and --report=source shows which single sniff is responsible for most of your violation count. Nine times out of ten, one sniff plus one exclude-pattern is the whole fix.
How can I help you explore Laravel packages today?