- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You know the PR comment. "Our test classes should be final." Someone leaves it. The author fixes that one file. Next week a different author opens a PR with a non-final test class, and a different reviewer either misses it or leaves the same comment again. The convention lives in people's heads and in scattered review threads. It never lives in the codebase.
That is the wrong place to keep a rule. A convention that only exists in review comments isn't a convention. It's a lottery that depends on which reviewer showed up.
PHP-CS-Fixer already automates the style rules everyone agrees on: braces, imports, spacing. What most teams miss is that you can write your own rules the same way. Any convention you can describe by looking at the tokens, you can encode once and enforce forever. This post is about doing that: writing a custom fixer, wiring it into a config, and deciding when it runs as an auto-fix versus a CI gate.
Rules are just token transformations
PHP-CS-Fixer doesn't read your code as text. It tokenizes it with PHP's own tokenizer, hands you a Tokens collection, and asks each rule to walk that collection and rewrite it. Same engine as the built-in rules. No regex, no fragile string matching.
A rule is a class that implements PhpCsFixer\Fixer\FixerInterface. Seven methods, and most of them are one-liners. The one that does work is fix().
Here's a complete, working fixer that marks every test class final. It's the "our test classes should be final" comment, turned into code that never forgets.
<?php
declare(strict_types=1);
namespace App\PhpCsFixer;
use PhpCsFixer\Fixer\FixerInterface;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;
use SplFileInfo;
final class FinalTestClassFixer implements FixerInterface
{
public function getName(): string
{
return 'App/final_test_class';
}
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Test classes must be declared final.',
[new CodeSample("<?php\nclass FooTest {}\n")],
);
}
public function isCandidate(Tokens $tokens): bool
{
return $tokens->isTokenKindFound(T_CLASS);
}
public function isRisky(): bool
{
return false;
}
public function getPriority(): int
{
return 0;
}
public function supports(SplFileInfo $file): bool
{
return str_ends_with($file->getFilename(), 'Test.php');
}
public function fix(SplFileInfo $file, Tokens $tokens): void
{
for ($i = $tokens->count() - 1; $i > 0; $i--) {
if (!$tokens[$i]->isGivenKind(T_CLASS)) {
continue;
}
$prev = $tokens->getPrevMeaningfulToken($i);
// skip abstract, already-final, ::class, new class
$skip = [T_ABSTRACT, T_FINAL, T_DOUBLE_COLON, T_NEW];
if ($tokens[$prev]->isGivenKind($skip)) {
continue;
}
$tokens->insertAt($i, [
new Token([T_FINAL, 'final']),
new Token([T_WHITESPACE, ' ']),
]);
}
}
}
Read fix() from the top. It walks the tokens backwards so that inserting a token never shifts the indices it hasn't visited yet. For each T_CLASS, it looks at the previous meaningful token. If that token is abstract, final, :: (a Foo::class reference), or new (an anonymous class), it leaves it alone. Otherwise it inserts final in front of the class keyword.
Those four skip cases are the whole reason a fixer beats a regex. Foo::class contains the class keyword too. A text search would trip on it. The tokenizer tells you exactly what each class is.
The name and the pattern that trips people up
getName() returns App/final_test_class. That slash-separated shape isn't decoration. PHP-CS-Fixer validates custom rule names against ^[A-Z][a-zA-Z0-9]*\/[a-z][a-z0-9_]*$. Vendor prefix, a slash, then a snake_case rule name. Get it wrong and the fixer won't register.
isCandidate() is the fast pre-filter. Before running the expensive fix() walk, PHP-CS-Fixer asks each rule whether the file even contains the tokens it cares about. No T_CLASS in the file, no work. Keep this cheap.
isRisky() matters more than it looks. A rule is risky if it could change runtime behavior. Adding final to a test class is safe, so this returns false. If you write a rule that, say, swaps a function call for another, mark it risky and force users to opt in. Lying here is how you ship a silent behavior change to a hundred repos.
Wiring it into the config
Custom fixers get registered on the Config object. Here's a .php-cs-fixer.dist.php that pulls in a standard ruleset, a PHP 8.4 migration set, and your one custom rule:
<?php
declare(strict_types=1);
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests');
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setParallelConfig(
PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect(),
)
->registerCustomFixers([
new App\PhpCsFixer\FinalTestClassFixer(),
])
->setRules([
'@PER-CS2.0' => true,
'@PHP84Migration' => true,
'declare_strict_types' => true,
'App/final_test_class' => true,
])
->setFinder($finder);
Two things worth calling out. setParallelConfig(...->detect()) turns on the parallel runner that shipped in PHP-CS-Fixer 3.57. On a multi-core machine and a large repo, it cuts wall-clock time by a lot, and your custom fixer runs across the workers with no extra work from you. And the @PER-CS2.0 set applies the current PER Coding Style, the PSR-successor ruleset the PHP-FIG maintains, which is where new projects should start rather than the older @PSR12.
Your fixer class also has to be autoloadable. Add its namespace to the dev autoloader so PHP-CS-Fixer can find it:
{
"autoload-dev": {
"psr-4": {
"App\\PhpCsFixer\\": "tools/php-cs-fixer/"
}
}
}
Run composer dump-autoload once and the rule is live.
Config sets: the base you build on
You almost never want to list rules one at a time. PHP-CS-Fixer ships curated sets, and you enable a set and then override individual rules. The ones worth knowing:
-
@PER-CS2.0— the current PER Coding Style, the successor to PSR-12. Start here. -
@Symfony— Symfony's house style. Stricter than PER, opinionated about ordering and spacing. -
@PhpCsFixer— the maintainers' own set. Strictest of the shipped ones. -
@PHP84Migration— rewrites older syntax into 8.4-era equivalents. Pair it with a migration set matching your minimum PHP version.
Order matters. Later entries in setRules() win. Enable a base set, then flip specific rules off or reconfigure them:
->setRules([
'@Symfony' => true,
'yoda_style' => false, // we write $x === 1, not 1 === $x
'concat_space' => ['spacing' => 'one'],
'App/final_test_class' => true,
])
Your custom rules sit in the same list as the built-ins. To the config, there's no difference between a rule you wrote and one that ships in the box.
Auto-fix or CI gate: pick per context
The same rule set runs in two modes, and the mode is where teams get the ergonomics wrong.
Locally, auto-fix. Developers want the tool to just fix the file. Run it in write mode from a pre-commit hook or a composer script:
vendor/bin/php-cs-fixer fix
No flags. It rewrites files in place. The developer's diff is already clean before it leaves their machine.
In CI, check only. The pipeline should never rewrite code. It should tell you the code is wrong and fail. That's --dry-run, which makes PHP-CS-Fixer exit non-zero when a file would change:
vendor/bin/php-cs-fixer fix --dry-run --diff
--diff prints exactly what would change, so a failed build shows the reviewer the offending lines instead of a bare exit code.
A GitHub Actions job that gates every PR:
name: Coding standards
on:
pull_request:
paths:
- '**.php'
- '.php-cs-fixer.dist.php'
jobs:
php-cs-fixer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install
run: composer install --no-progress
- name: Check style
run: >
vendor/bin/php-cs-fixer fix
--dry-run --diff --no-interaction
Add PHP_CS_FIXER_IGNORE_ENV only if you run on a PHP version the tool hasn't officially blessed yet. Otherwise leave it off, so an unexpected version bump surfaces loudly instead of running with a warning suppressed.
The split is the point. Auto-fix on the way in keeps developers from ever thinking about style. The dry-run gate makes sure nobody bypasses the hook. The rule is written once and enforced in both places, and no human re-litigates it in a review thread.
What this buys you
The final example is small on purpose. The mechanism is the win. Any convention you can spot by looking at the tokens, you can encode: constructors declared before other methods, DTOs marked readonly, forbidden functions like dd() or var_dump() stripped from committed code, framework facades banned from your domain layer.
Each of those is a PR comment you're tired of writing. Turn it into a fixer once and it stops being a comment. It becomes a property of the codebase, checked on every push, applied on every save. Nobody wins the style argument. It just stops being a human's job.
Where teams keep their conventions in a wiki page nobody reads, the ones who write it as a fixer keep it where it can't be ignored: in the pipeline.
If this was useful
The most valuable custom fixers are usually boundary rules: your domain layer may not import the ORM, your entities may not know about HTTP, your value objects must be final readonly. A linter can enforce the boundary, but only once you've decided where the boundary is. That decision is the whole subject of Decoupled PHP — keeping the framework at the edge so the code at the center outlives it.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)