mfn/php-cs-fixer-config
Opinionated php-cs-fixer rule set for v3.11 and up, packaged as a simple config class. Requires setRiskyAllowed(true). Just require the autoloader, grab the rules, and pass them to your PhpCsFixer config.
Install (dev-only, it is a config array, not runtime code):
composer require --dev mfn/php-cs-fixer-config friendsofphp/php-cs-fixer
The package itself has no runtime API surface beyond one static method, so it should never land in require.
Create .php-cs-fixer.dist.php at repo root:
<?php
require 'vendor/autoload.php';
$finder = (new PhpCsFixer\Finder())
->in([__DIR__ . '/src', __DIR__ . '/tests', __DIR__ . '/config', __DIR__ . '/database'])
->exclude(['vendor', 'storage', 'bootstrap/cache', 'node_modules'])
->notPath('*.blade.php')
->append([__FILE__]);
return (new PhpCsFixer\Config())
->setFinder($finder)
->setRiskyAllowed(true) // required — getRules() ships risky rules
->setRules(\Mfn\PhpCsFixer\Config::getRules());
Where to look first: there is essentially one file worth reading — the class behind Mfn\PhpCsFixer\Config::getRules(). Read the returned array in vendor/mfn/php-cs-fixer-config/src/ before you run anything. That array is the package. Everything else (README, release notes) is changelog noise about which rule names were added or renamed.
First use case — audit before you mutate:
vendor/bin/php-cs-fixer fix --dry-run --diff --using-cache=no
Do not skip --dry-run. Because risky rules are enabled, the first run on an existing codebase can produce a large diff (indentation, trailing commas, native_function_invocation, parentheses around new, nullable type declarations). Read the diff, decide whether you accept it, then commit the resulting formatting change as a single isolated commit so git blame churn is easy to ignore.
If the diff is enormous, scope the first pass to one directory:
vendor/bin/php-cs-fixer fix src/ --dry-run --diff
Then wire a Composer script so the team uses the same invocation:
"scripts": {
"cs": "php-cs-fixer fix",
"cs:check": "php-cs-fixer fix --dry-run --diff --using-cache=no"
}
Treat getRules() as a base array, not a black box. It returns a plain PHP array keyed by rule name, so the idiomatic pattern for any disagreement is to merge and override locally rather than fork the package:
$rules = \Mfn\PhpCsFixer\Config::getRules();
// Turn one rule off entirely
$rules['native_function_invocation'] = false;
// Or narrow it — only prefix calls in the global namespace
$rules['native_function_invocation'] = [
'include' => ['@compiler_optimized'],
'scope' => 'namespaced',
];
return (new PhpCsFixer\Config())->setRules($rules);
Document every override next to the config with a one-line why. Otherwise the next developer will assume the base ruleset is authoritative and "fix" your override.
Split by path when src/ and tests/ have different needs. PHP-CS-Fixer supports multiple Config objects via PhpCsFixer\Config\ConfigResolver-style setups, but the simpler day-to-day pattern is a single finder with ->in([...]) plus ->notPath() exclusions. Keep generated code, migrations you never touch again, and Blade templates out of the finder rather than adding rules to suppress them — exclusion is cheaper and less surprising than rule-level exceptions.
CI gate pattern. Run only the check variant in CI, with cache disabled so results are deterministic:
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/php-cs-fixer fix --dry-run --diff --using-cache=no --format=github
--format=github turns violations into inline annotations on the PR, which is the single highest-leverage integration for reducing review ping-pong.
Local fast loop. Keep the cache on locally (.php-cs-fixer.cache, git-ignored) and run composer cs before pushing. For large repos, --parallel (default in v3) plus the cache makes a full run sub-second after the first pass.
Incremental adoption on legacy code. Two workable strategies:
.git-blame-ignore-revs entry pointing at that commit's SHA so git blame skips it. Configure blame.ignoreRevsFile in .gitconfig or the repo's .git-blame-ignore-revs (GitHub honors this automatically).Big-bang is almost always the better default: partial adoption means every PR touches both formatted and unformatted code, and reviewers can't tell style churn from logic changes.
IDE integration. Point PhpStorm's File Watcher or the VS Code junstyle.php-cs-fixer extension at .php-cs-fixer.dist.php and the local binary. The value of this package is precisely that there's one canonical rules file — if developers run IDE defaults, the package's whole point evaporates.
Pre-commit hook (optional but cheap). A lint-staged/captainhook step running php-cs-fixer fix --path-mode=intersection on staged files catches drift before CI. Intersection mode passes only files that match both the CLI args and the Finder, which is what you want.
setRiskyAllowed(true) is mandatory, not optional. The README states it explicitly. Without it, risky rules in the returned array are silently dropped or error depending on the php-cs-fixer version — either way, your local and CI runs can disagree in confusing ways. Assert it once in the config and never think about it again.
Version coupling is the sharpest edge. The ruleset targets php-cs-fixer v3.11+. Release 2.9.0 replaced 12 deprecated rules with their successors, and 2.1.0 swapped no_trailing_comma_in_list_call for no_trailing_comma_in_singleline. That means:
php-cs-fixer + new config → Unknown rule exceptions at startup.php-cs-fixer + old config → deprecated-rule warnings, and eventually hard failures when Symfony removes them.Pin both in composer.json and bump them together in one PR. Add a lockfile refresh to your dependency-update routine so a composer update on a Monday morning doesn't break the build on an unrelated branch.
Stale cache produces "no changes" lies. After bumping the package or changing the rules array, delete .php-cs-fixer.cache (or run with --using-cache=no). The cache key doesn't fully account for rule-array contents on all versions, so a rule change can appear to have no effect.
native_function_invocation is the rule most likely to bite. Release 1.0.2 added compiler_optimized to the include list. That means calls to count(), in_array(), etc. get rewritten to \count(), \in_array(). This is generally fine, but if you have global helper function shims (common in older Laravel codebases and some packages), namespaced-scope prefixing can change which function resolves. If you see behavior changes rather than pure style changes, that rule is your first suspect — narrow it to ['@compiler_optimized'] or disable it.
nullable_type_declaration_for_default_null_value (added in 2.6.0) is a PHP 8.4 forward-compat fix. It turns function foo(string $x = null) into function foo(?string $x = null). Correct, but it changes signatures in a way that shows up in diffs on public APIs and can trip reflection-based tests or doc generators. Verify your test suite covers any signature-sensitive code (mocks generated from interfaces, API doc generators).
There is no license file. The README says "None? Pick what you want?" For a config-only dev dependency this is a low-risk but real friction point — some legal/OSS-review processes will block a package with no license. If that applies to you, get an explicit decision before pitching adoption, or vendor the ~50-line rules array into your own repo and drop the dependency.
Issues are disabled by design. "If you feel strongly about something, please create a PR." Practically: you cannot file a bug, and with 0 stars / 0 dependents you should assume no third-party answers to your questions. Budget for the possibility of forking. The good news is that the fork surface is one file; the bad news is you now own the maintenance.
Rule naming collides with Laravel Pint. Pint wraps PHP-CS-Fixer and uses preset names (laravel, per, symfony) and its own config key set. If a project uses Pint, the pattern is pint.json with "preset": "none" plus explicit rules — or run Pint or PHP-CS-Fixer, never both. A getRules() array can technically be fed to Pint's "rules" key, but Pint's bundled PHP-CS-Fixer version may not know newer rule names. If you're on a Laravel app already using Pint, evaluate whether Pint + a custom preset gets you 90% of this package's value with zero extra dependencies.
Blade and vendor must be excluded at the Finder level. The README gives you ->setFinder(…) as an ellipsis, and this is exactly where people get burned: running the fixer over *.blade.php, over vendor/, or over bootstrap/cache/config.php will rewrite third-party and generated files. Triple-check the Finder before the first real run. --dry-run --diff on a fresh clone is the safety net.
Debugging an individual rule: vendor/bin/php-cs-fixer describe <rule_name> prints the rule's behavior, examples, and options — faster than reading the changelog to figure out what array_indentation (added 2.10.0) or no_unneeded_import_alias (2.2.0) actually do. For why a specific file changes, -vvv on --dry-run --diff names the rule behind each hunk.
Extension points are limited to the array. There is no interface, no event, no service provider. The only extension mechanism is array_merge/key reassignment on the returned array, plus Finder configuration. Design your local config file accordingly: base rules from the package, overrides and additions in a clearly-delimited block below, with comments. That file is your entire integration and it should read like one.
Zero stars and zero dependents is itself a signal. For a formatting tool, that's genuinely fine — the worst case is that you revert one commit and vendor twenty lines of array. But it argues for not making this the foundation of a shared org-wide standard without a fallback. The pragmatic middle ground: adopt it, keep your overrides in a local .php-cs-fixer.dist.php that is self-documenting enough to survive the package disappearing tomorrow.
How can I help you explore Laravel packages today?