The Code Quality Toolchain: Formatting, Linting, Refactoring, and Gates

Distinguish formatting (PHP-CS-Fixer, PHPCS/PHPCBF) from static analysis and from linting (PHPMD), understand what Rector automates for large-scale refactors and version upgrades, wire tools into pre-commit hooks and CI quality gates, and connect all of it to how a healthy code review actually works.

'Code quality' tooling in PHP is really four distinct jobs wearing similar-looking CLI tools: making code look consistent (formatting), catching code that's syntactically valid but structurally worrying — too complex, too coupled, dead (linting/mess detection), catching code that's provably wrong by type (static analysis, covered in its own lesson), and mechanically rewriting code across an entire codebase (automated refactoring). Confusing these four is the single most common reason teams end up with a toolchain that either does too little or fights itself — running a formatter and a linter that disagree about spacing, or expecting PHPStan to catch a 400-line method it was never designed to flag. This lesson keeps them separate and shows how they compose into one pipeline: pre-commit locally, quality gate in CI, and a review process for what tools can't automate.

PHP_CodeSniffer and PHP-CS-Fixer: formatting and style, not correctness

PHP_CodeSniffer (PHPCS) checks code against a coding standard (PSR-12 is the common default) and reports violations — indentation, brace placement, spacing around operators, naming conventions. Its companion phpcbf (PHP Code Beautifier and Fixer) automatically rewrites the file to fix whatever violations it can. PHP-CS-Fixer is a separate, more actively developed tool that does the same fundamental job — enforce and auto-fix a style — but with a larger built-in rule library (hundreds of individually toggleable 'fixers': no_unused_imports, single_quote, ordered_imports, and so on) and its own config format (.php-cs-fixer.php, a PHP file returning a Config object rather than XML).

Both tools are purely syntactic: they reformat how correct code is written, they do not — and cannot — tell you whether the code is correct. A file can pass PHPCS/PHP-CS-Fixer with zero violations and still call an undefined method, because 'is this file formatted per PSR-12' and 'does this code do what it claims to do' are unrelated questions. Most teams pick exactly one of PHPCS or PHP-CS-Fixer (running both on the same codebase invites them fighting over the same lines) and run its auto-fix mode (phpcbf or php-cs-fixer fix) rather than only the check mode, since a violation that can be fixed automatically should never need a human to fix it by hand.

PHP Mess Detector (PHPMD): structural warning signs

PHPMD looks for a different category of problem entirely: not 'is this formatted correctly' and not 'is this type-correct,' but 'does this code exhibit known warning-sign patterns even though it's syntactically fine and type-correct.' Its built-in rule sets flag things like excessive cyclomatic complexity (a method with too many branching paths to reason about confidently), unused private fields/parameters/local variables, excessively long methods or classes, too many parameters on a method signature, and naming that violates conventions (a boolean method not prefixed is/has, for example, under some rule sets).

PHPMD's checks are heuristic, not provable — a 15-branch method flagged for high cyclomatic complexity might genuinely need restructuring, or might be an unavoidable big match expression that's actually the clearest way to express that particular piece of logic. This is why PHPMD findings are usually treated as prompts for a human judgment call in review rather than a hard CI-blocking gate the way a static-analysis type error typically is — the tool is good at pointing at code worth a second look, not at proving something is wrong.

Rector: automated refactoring and version-upgrade migrations

Rector operates on a different axis from formatting and linting entirely: it doesn't report problems for a human to fix, it rewrites code according to a configured set of transformation rules, applied across the whole codebase mechanically. Its most common real-world use is version migration — upgrading a codebase from PHP 7.4 to 8.2, or from an old framework API to a new one — by applying rule sets like LevelSetList::UP_TO_PHP_82, which mechanically rewrites every applicable call site: converting array-based type hints to union types, adding readonly where Rector can prove a property is never reassigned, replacing deprecated function calls with their modern equivalents, across potentially thousands of files in one run instead of a developer hand-editing each one.

Rector is run with --dry-run first (show the diff without writing anything) so a human reviews the scope of changes before they're applied, then rector process actually rewrites the files, and the result is committed as a normal (if often very large) diff, reviewed like any other change. It's not a replacement for understanding what changed — a Rector run that rewrites 400 files still needs someone to spot-check a representative sample and run the full test suite, because a mechanical rule applied blindly across an entire codebase can occasionally misfire on an edge case the rule's author didn't anticipate.

Formatting vs static analysis vs linting: keeping the boundaries straight

It's worth stating the three-way distinction explicitly because tool names don't make it obvious. Formatting (PHPCS, PHP-CS-Fixer) answers 'is this written the way the team agreed to write code' — purely cosmetic, always auto-fixable, never about correctness. Static analysis (PHPStan, Psalm — covered in depth in the Static Analysis lesson) answers 'is this code type-correct and free of provable logic contradictions' — never cosmetic, sometimes auto-fixable but usually requires a real code change. Linting/mess detection (PHPMD) answers 'does this code exhibit a known structural warning sign' — heuristic, judgment-requiring, rarely auto-fixable because fixing 'this method is too complex' means actually redesigning it.

A healthy toolchain runs all three, in that rough order of cost: formatting first (cheapest, auto-fixes almost everything, no reason to ever fail CI on a fixable style issue), then static analysis (catches real bugs, should block a merge), then mess detection (surfaces things worth a reviewer's attention, often non-blocking or only blocking above a severity threshold).

Pre-commit hooks: catching problems before they leave the developer's machine

A pre-commit hook is a script git runs automatically before finalizing a commit, and it can reject the commit outright if the script exits non-zero. In PHP projects this is usually wired through composer.json scripts ("lint": "phpcs && phpstan analyse") invoked from a .git/hooks/pre-commit script, or via a dedicated hook-management tool (Husky-style hook runners exist for PHP via Composer plugins like captainhook/captainhook or brainmaestro/composer-git-hooks, mirroring the same idea popularized by Husky in the Node ecosystem) — the specific plumbing matters less than the principle: fast checks (formatting, and ideally a narrowly-scoped static analysis run against only staged files) run locally in the seconds before a commit lands, so a developer gets feedback before pushing rather than minutes later when CI reports it.

The key design constraint is speed: a pre-commit hook that takes three minutes trains developers to reach for --no-verify and skip it entirely, defeating its purpose. Practical setups run only formatting checks (fast, always) plus static analysis scoped to changed files rather than the whole codebase pre-commit, reserving the full, slower analysis run — and anything mess-detection or architecture-rule related — for CI, where a few extra minutes on a pull request doesn't block anyone's local workflow.

CI quality gates and code review practices

A quality gate is a CI pipeline stage that must pass before a pull request can merge — typically formatting check (not auto-fix, since CI should verify, not silently rewrite a contributor's branch), full static analysis at the project's current level/baseline, the full test suite, and often a coverage or mutation-score threshold (see the Mutation Testing lesson). Each of these should be a separate CI job or step so a failure is immediately attributable — 'the PHPStan step failed' is far more actionable to a developer than one monolithic 'checks failed' status.

None of this automation replaces human code review — it changes what review is for. When formatting, type-correctness, and obvious structural smells are all caught mechanically before a human ever opens the diff, a reviewer's attention is freed to focus on what tools fundamentally cannot judge: is this the right design for the problem, does this change what a system boundary means, is the naming actually clear to someone unfamiliar with this code, does this test actually verify the behavior it claims to. Teams that skip the toolchain end up spending review time on exactly the things a machine should have caught first, which is both slower and worse at catching the things only a human can catch.

composer.json scripts wiring the whole toolchain together

composer run quality becomes the single command both a pre-commit hook (a fast subset of it) and CI (the whole thing) can invoke, keeping local and CI checks defined in exactly one place. format:check (dry-run) is what CI runs; format (actually rewriting files) is what a developer runs locally, never the other way around.

{
    "scripts": {
        "format": "php-cs-fixer fix",
        "format:check": "php-cs-fixer fix --dry-run --diff",
        "lint": "phpmd src text phpmd-ruleset.xml",
        "analyse": "phpstan analyse --no-progress",
        "test": "phpunit",
        "quality": [
            "@format:check",
            "@analyse",
            "@lint",
            "@test"
        ]
    }
}

Code Examples

The '@PSR12' ruleset pulls in the standard's full set of formatting rules as a baseline, and individual rules layered on top (declare_strict_types, no_unused_imports) enforce team-specific conventions beyond what PSR-12 itself mandates. Running php-cs-fixer fix rewrites every file under src/ and tests/ to match this exactly; php-cs-fixer fix --dry-run --diff (used in CI) reports what would change without touching any file. declare_strict_types as a fixer rule will insert declare(strict_types=1); into any file missing it — a formatting rule that happens to nudge the codebase toward stricter type behavior.

'declare_strict_types' => true, Running vendor/bin/rector process --dry-run against this config shows every mechanical rewrite Rector would make across src/ (excluding Legacy/) — union-type conversions, readonly-property promotion where provably safe, dead-code removal — without touching a file. Dropping --dry-run applies them for real, producing a diff a human then reviews and runs the test suite against before merging. Expected result: A diff report listing every file and the specific rule that triggered each change, reviewable before any file is actually modified.

<?php

declare(strict_types=1);

$finder = (new PhpCsFixer\Finder())
    ->in(__DIR__ . '/src')
    ->in(__DIR__ . '/tests');

return (new PhpCsFixer\Config())
    ->setRules([
        '@PSR12' => true,
        'strict_param' => true,
        'declare_strict_types' => true,
        'no_unused_imports' => true,
        'ordered_imports' => ['sort_algorithm' => 'alpha'],
        'single_quote' => true,
        'trailing_comma_in_multiline' => true,
    ])
    ->setFinder($finder);
<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;
use Rector\Set\ValueObject\SetList;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__ . '/src',
    ]);

    // Mechanically upgrade syntax/APIs to target PHP 8.2 idioms.
    $rectorConfig->sets([
        LevelSetList::UP_TO_PHP_82,
        SetList::CODE_QUALITY,
        SetList::DEAD_CODE,
    ]);

    $rectorConfig->skip([
        __DIR__ . '/src/Legacy',
    ]);
};

The four-job pipeline, cheapest and most-automatic first

Formatting and static analysis are automated gates; PHPMD findings and design review are where human judgment still does the work.

git commit
       |
       v
  pre-commit hook (fast, staged files only)
       |  php-cs-fixer fix (auto-fix formatting)
       |  phpstan analyse --memory-limit=1G (staged files)
       v
  push  ->  CI pipeline
       |
       +--> format:check  (dry-run, must already be clean)
       +--> analyse        (full codebase, current level + baseline)
       +--> lint (PHPMD)   (structural warnings, often non-blocking)
       +--> test (PHPUnit + Infection)
       |
       v
  all pass --> mergeable  |  human code review focuses on design, not style

Why Rector runs are reviewed like feature PRs, never auto-merged

Even though Rector's rules are individually well-tested, a single Rector run against a large codebase (converting to PHP 8.2 syntax across 3,000 files, say) is treated as a substantial PR requiring the same review rigor as a hand-written change of that size — full CI run, a human skimming a representative sample of the diff, and often a staged rollout rather than merging straight to production. The mechanical nature of the change reduces the chance of a mistake per line, but the sheer number of touched lines means the expected number of edge cases hit across the whole run is not zero, and nobody wants to discover that in production.

// CI step: php-cs-fixer fix (no --dry-run) run directly in the pipeline, // committing auto-formatted changes back onto the contributor's branch.

// CI step: php-cs-fixer fix --dry-run --diff — reports and FAILS if // anything would change; the contributor runs php-cs-fixer fix locally // and commits the result themselves.

Having CI silently rewrite and push commits onto a contributor's branch is surprising, can race with their own local commits, and hides formatting problems instead of teaching the pre-commit hook to catch them earlier. CI's job is to verify the branch is already correctly formatted, not to fix it on the contributor's behalf.

What is the core difference between what PHP-CS-Fixer/PHPCS check and what PHPStan/Psalm check? A. There is no real difference; they check the same things with different syntax B. PHP-CS-Fixer/PHPCS check code formatting and style (cosmetic, always auto-fixable); PHPStan/Psalm check type-correctness and logic contradictions (never cosmetic, not always auto-fixable) C. PHPCS only works on PHP 7, PHPStan only works on PHP 8 D. PHPStan checks formatting and PHPCS checks types

B. PHP-CS-Fixer/PHPCS check code formatting and style (cosmetic, always auto-fixable); PHPStan/Psalm check type-correctness and logic contradictions (never cosmetic, not always auto-fixable) — Formatting tools answer 'is this written the way the team agreed to write code' and can always be auto-fixed since there's exactly one correct formatted output; static analyzers answer 'is this code type-correct,' which is a fundamentally different, non-cosmetic question that usually requires an actual code change to resolve.

What does Rector do that PHPStan and PHP-CS-Fixer do not? A. It reports type errors at a stricter level than PHPStan B. It mechanically rewrites code according to configured rules — e.g. migrating an entire codebase to a newer PHP version's syntax — rather than only reporting problems for a human to fix C. It formats code according to PSR-12 D. It runs the test suite and reports coverage

B. It mechanically rewrites code according to configured rules — e.g. migrating an entire codebase to a newer PHP version's syntax — rather than only reporting problems for a human to fix — Rector applies automated, rule-based rewrites across the whole codebase (version upgrades, deprecated-API replacement, dead-code removal) rather than reporting findings for a human to act on — it's the only one of the three whose job is to change code itself, not just check it.

Why should a pre-commit hook typically run only formatting checks and a narrowly-scoped static analysis pass, rather than the full quality-gate suite? A. Full checks are not possible to run outside of CI B. A slow pre-commit hook trains developers to skip it (e.g. with --no-verify), so it should stay fast enough to run on every commit, leaving slower full-codebase checks to CI C. PHPStan cannot run on staged-only files D. Pre-commit hooks are deprecated in favor of CI-only checks

B. A slow pre-commit hook trains developers to skip it (e.g. with --no-verify), so it should stay fast enough to run on every commit, leaving slower full-codebase checks to CI — A pre-commit hook's value depends on developers actually letting it run; a multi-minute hook gets bypassed, defeating its purpose. Keeping it fast (formatting plus a scoped analysis pass) while reserving the full suite for CI preserves both fast local feedback and thorough gate enforcement.

A team's PHPMD run flags a ReportGenerator::build() method for excessive cyclomatic complexity (22 branches) and PHPStan (level 6) reports zero errors on the same file. Explain why both results can be true simultaneously, and what each tool would and would not tell a reviewer about whether this method needs to be refactored. Hint: PHPStan only checks type-correctness — a 22-branch method can be perfectly type-safe. PHPMD's complexity flag is about how hard the method is for a human to reason about, which is an orthogonal, heuristic concern PHPStan was never designed to catch.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.