Enforcing PSR-12 in a Team: Tools Instead of Review Discussions
AI generated
<?php
8.4
PHP · Code Style · CI/CD · Tooling
Enforcing PSR-12 in a Team
tools instead of review discussions

Enforcing PSR-12 without blocking every pull request with comments about indentation and brace placement requires automation instead of good intentions. PHP_CodeSniffer and PHP-CS-Fixer, combined with pre-commit hooks and a CI pipeline, permanently end formatting discussions in review, even in grown legacy projects.

15 min read PHP_CodeSniffer · PHP-CS-Fixer · Pre-Commit · CI PHP 8.4 · Composer · Git Hooks

1. Why code style discussions cost time in review

In many teams, a significant part of review comments concerns not the logic of a change but its formatting: a missing blank line before a return, wrong indentation after a merge, an opening brace on the wrong line. These comments are usually uncontroversial from a technical standpoint, but they cost time, create friction between author and reviewer, and delay the actual substantive review across several iteration rounds. Anyone who wants to enforce PSR-12 should remove this category of comments from human review entirely, not through more discipline, but through tools that check the state objectively and automatically.

PSR-12 itself is an extension of the older PSR-2 standard from the PHP-FIG and covers details such as four-space indentation, line length, brace placement, blank lines between methods and the correct order of visibility modifiers. The standard itself is uncontroversial and broadly accepted, the actual problem almost never lies in the standard, but in its consistent, tool-supported enforcement across the entire codebase and all team members.

The following sections show how a team can go about enforcing PSR-12, from pure detection through automatic auto-fixing to safeguarding it in pre-commit hooks, CI pipeline and editor, and how it can be introduced even on a historically grown codebase without overwhelming the team with a single giant reformat commit.

2. PHP_CodeSniffer: enforcing PSR-12 as a ruleset

PHP_CodeSniffer, usually installed via the Composer command composer require --dev squizlabs/php_codesniffer, checks code against a declarative ruleset and reports every deviation with file, line and a unique sniff identifier. The built-in standard PSR12 already covers the entire official standard, a custom phpcs.xml in the project root extends or refines this standard with project-specific exceptions, for example for generated files or legacy directories.


<?xml version="1.0"?>
<ruleset name="ProjectPSR12">
    <description>PSR-12 with project-specific exclusions</description>

    <rule ref="PSR12"/>

    <file>src</file>
    <file>tests</file>

    <exclude-pattern>*/var/*</exclude-pattern>
    <exclude-pattern>*/vendor/*</exclude-pattern>
    <exclude-pattern>src/Legacy/*</exclude-pattern>

    <arg name="colors"/>
    <arg value="p"/>
    <arg name="extensions" value="php"/>
</ruleset>

Running vendor/bin/phpcs analyses the configured scope and lists every violation with its exact location, useful for a first overview of the scope of a rollout. Anyone enforcing PSR-12 without manually configuring every rule benefits from the fact that PSR12 as a standard already bundles all relevant sniffs, individual sniffs can be selectively disabled via <exclude> elements when needed, for example if a rule collides with a project-specific convention.

An important difference from many other linting tools: PHP_CodeSniffer primarily reports violations, it does not fix them by default. For automatic fixing there is the bundled sister tool phpcbf (PHP Code Beautifier and Fixer), which uses the same ruleset mechanism but overwrites files directly, provided the given sniff supports an automatic fix. Not every sniff is auto-fixable, more complex structural violations remain a manual task.

3. PHP-CS-Fixer: automatic reformatting instead of just reporting

PHP-CS-Fixer follows a different approach from PHP_CodeSniffer from the start: it is primarily an auto-fixer that rewrites code directly, pure reporting without correction is more the exception. Configuration happens via a PHP file .php-cs-fixer.php in the project root that returns a Config object with a set of enabled rules, either as a named rule set like @PSR12 or as a fine-grained list of individual rules.


<?php

declare(strict_types=1);

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

return (new PhpCsFixer\Config())
    ->setRules([
        '@PSR12' => true,
        'array_syntax' => ['syntax' => 'short'],
        'declare_strict_types' => true,
        'no_unused_imports' => true,
        'ordered_imports' => ['sort_algorithm' => 'alpha'],
        'single_quote' => true,
        'trailing_comma_in_multiline' => true,
    ])
    ->setFinder($finder)
    ->setRiskyAllowed(true);

Running vendor/bin/php-cs-fixer fix overwrites affected files directly, vendor/bin/php-cs-fixer fix --dry-run --diff instead only shows a preview diff without changing anything, useful for CI checks that should fail a build without modifying files. The flag --risky-allowed enables rules that could theoretically change the behavior of the code, for example switching from array() to [], in practice usually harmless for PSR-12 compliance.

The difference between phpcbf and php-cs-fixer fix lies mainly in the scope of what can be automatically corrected: PHP-CS-Fixer covers considerably more rules with automatic correction, because auto-fixing is the core concept of the tool from the start, whereas for PHP_CodeSniffer it is a later addition. Many teams therefore combine both tools: PHP_CodeSniffer for detection with a broader sniff catalog including architectural rules that are not auto-fixable, PHP-CS-Fixer for automatically reformatting the bulk of pure style violations.

4. Pre-commit hooks: blocking violations before the commit

A pre-commit hook prevents non-compliant code from ever entering the repository in the first place, instead of discovering it only in the pull request or in the CI pipeline. The decisive advantage over pure CI checking: the developer gets immediate feedback, locally, before a commit even exists, and the correction happens in the same work step instead of in a separate fix-up commit later.


#!/usr/bin/env bash
# .git/hooks/pre-commit (or managed via a tool like Husky/Captain Hook)
set -euo pipefail

# Only check staged PHP files, not the entire codebase
staged_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php')

if [ -z "$staged_files" ]; then
    exit 0
fi

echo "Running PHP-CS-Fixer on staged files..."
vendor/bin/php-cs-fixer fix --dry-run --diff $staged_files

if [ $? -ne 0 ]; then
    echo "PSR-12 violations found. Run 'vendor/bin/php-cs-fixer fix' and re-stage."
    exit 1
fi

echo "Running PHP_CodeSniffer on staged files..."
vendor/bin/phpcs $staged_files

The hook deliberately limits the check to staged files instead of scanning the entire codebase every time, that keeps the runtime in the range of seconds and makes the hook practical for daily use. Anyone enforcing PSR-12 without frustrating developers with long waits on every commit should deliberately keep this scope narrow and check the entire codebase in CI instead.

For teams with multiple languages or more complex hook requirements, tools like Captain Hook or Grumphp are worth considering, both PHP-native alternatives to generic solutions like Husky, which register Composer scripts directly as Git hooks and are managed via a declarative configuration file, instead of manually maintaining raw shell scripts.

5. CI integration: the build as the final authority

Pre-commit hooks can be bypassed with git commit --no-verify, intentionally or by accident, which is why the CI pipeline remains the actual, non-bypassable enforcement authority. A CI job that runs on every push and every pull request ensures that no non-compliant code ever reaches the main branch, regardless of whether the local hook was active or not.


# .github/workflows/code-style.yml
name: Code Style

on: [push, pull_request]

jobs:
    phpcs:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4
            - uses: shivammathur/setup-php@v2
              with:
                  php-version: '8.4'
            - run: composer install --prefer-dist --no-progress
            - name: Check PSR-12 compliance
              run: vendor/bin/phpcs --report=checkstyle
            - name: Check PHP-CS-Fixer rules
              run: vendor/bin/php-cs-fixer fix --dry-run --diff

The flag --report=checkstyle outputs results in checkstyle XML format, which many CI systems and code review tools can display directly as inline comments in the diff, instead of only delivering raw console output. Anyone implementing enforcing PSR-12 as a hard CI gate should definitely mark the job as a required check in the pull request workflow, so a merge without a passed style check is technically impossible, instead of just showing a non-binding warning.

For faster feedback cycles, a separate, fast job exclusively for changed files in the pull request pays off, combined with a full but less frequent scan of the entire codebase, for example nightly, to catch creeping deviations from faulty configuration changes early.

6. Editor integration: format-on-save in PhpStorm and VS Code

The most effective automation is the one a developer never consciously notices: format-on-save automatically formats code when saving, so a developer practically never has to manually establish PSR-12 compliance. PhpStorm supports PHP-CS-Fixer natively via a setting under Settings → PHP → Quality Tools → PHP-CS-Fixer, where the path to the executable and to the .php-cs-fixer.php configuration is stored, after which "Run on save" can be enabled for the current file scope.

In VS Code, the extension junstyle.php-cs-fixer handles the same task, configured via editor.formatOnSave: true combined with "[php]": { "editor.defaultFormatter": "junstyle.php-cs-fixer" } in the workspace settings. Important for team consistency: these editor settings should be versioned per project in .vscode/settings.json, instead of relying on each developer's individual, local configuration, otherwise every editor formats according to its own, potentially differing rules.

Format-on-save drastically reduces the number of violations that even reach the pre-commit hook in the first place, because the code is already correctly formatted when saved. Anyone consistently enforcing PSR-12 at all three levels, editor, pre-commit hook and CI, experiences in practice hardly any actual CI failure due to formatting anymore, because the earlier stages already catch most cases beforehand.

7. Rolling out on legacy code without a big-bang reformat

Bringing an existing project that has grown over several years to PSR-12 directly with a single giant reformat commit sounds tempting, but it produces a diff with thousands of changed lines that makes git blame practically useless for almost the entire history and provokes merge conflicts in every parallel-running feature branch. A step-by-step migration is the better strategy in almost all cases.

Git has offered a targeted solution for this since version 2.23: .git-blame-ignore-revs lists commit hashes that git blame skips by default, configured via git config blame.ignoreRevsFile .git-blame-ignore-revs. A one-time, full reformat commit lands in this file, git blame then still shows the original, technical author for every affected line, instead of the reformat commit across the board.


# One-time full reformat, then register the commit to be ignored by blame
vendor/bin/php-cs-fixer fix
git add -A
git commit -m "style: apply PSR-12 formatting across the codebase"

# Add the resulting commit hash to .git-blame-ignore-revs
echo "$(git rev-parse HEAD)  # PSR-12 mass reformat" >> .git-blame-ignore-revs
git add .git-blame-ignore-revs
git commit -m "chore: ignore PSR-12 reformat commit in git blame"

# Each contributor configures git locally (or via .gitconfig checked into
# the repo root and referenced via includeIf)
git config blame.ignoreRevsFile .git-blame-ignore-revs

Alternatively, for projects where even a one-time reformat commit seems too risky, for example due to ongoing release branches, a baseline approach analogous to PHPStan is worthwhile: an initial phpcs run over the entire codebase is stored as a reference state, the CI check then only fails if new or changed files introduce new violations, unchanged legacy code is tolerated for now and gradually corrected alongside changes that were happening anyway.

In practice a combination often proves worthwhile: new modules and actively developed directories are immediately brought fully to enforcing PSR-12, clearly bounded, barely touched legacy areas remain excluded via exclude-pattern for now and are caught up when convenient, for example as part of a refactoring that was planned anyway.

8. What is left in review afterward

After full automation, comments about indentation, brace placement and blank lines disappear completely from review, the freed-up time budget shifts entirely to substantive questions: is the chosen architecture sensible, are edge cases covered, is a method name self-explanatory. This shift is the actual value of the automation, not the formatting itself, but the attention freed up for things no tool can automatically check.

A secondary, often underestimated effect: new team members no longer have to derive the company's internal style from example code or oral tradition, the configuration file itself is the binding, executable documentation of the style. This reduces onboarding time and prevents situations where different team members hold different, informal ideas about the "correct" style.

9. PHP_CodeSniffer vs. PHP-CS-Fixer compared

Both tools complement each other in practice rather than one fully replacing the other. The following overview shows the most important differences for deciding which tool takes which role.

Dimension PHP_CodeSniffer PHP-CS-Fixer
Primary concept Detection and reporting of violations Automatic rewriting of code
Auto-fix coverage Partial, via separate phpcbf Extensive, core function
Rule catalog Very broad, including architecture sniffs Primarily pure formatting
Configuration format XML ruleset PHP file with config object
CI friendliness Checkstyle report for diff comments --dry-run --diff for non-blocking checks

The most productive combination for most teams: PHP-CS-Fixer for automatic formatting in the editor and in the pre-commit hook, PHP_CodeSniffer for the full rule check in CI, including sniffs that go beyond pure formatting, for example use of deprecated functions or missing visibility modifiers.

10. Summary

Enforcing PSR-12 does not succeed through more discipline or more extensive style guides, but through tools that check the state objectively and automatically correct it where possible. PHP_CodeSniffer with a phpcs.xml ruleset covers detection, PHP-CS-Fixer with .php-cs-fixer.php takes over automatic rewriting of the bulk of violations. Pre-commit hooks catch problems locally before they are even committed, the CI pipeline remains the final, non-bypassable authority.

Editor integration with format-on-save reduces the number of violations that arise in the first place to a minimum, a rollout via .git-blame-ignore-revs or a baseline strategy enables migrating existing, grown codebases without a risky big-bang commit. The actual gain shows up in review: comments about formatting disappear completely, the freed-up attention flows into substantive questions that no tool can automatically answer.

Enforcing PSR-12 in a Team - The essentials at a glance

Detection and fix

PHP_CodeSniffer with phpcs.xml for detection, PHP-CS-Fixer with .php-cs-fixer.php for automatic rewriting.

Three lines of defense

Editor (format-on-save), pre-commit hook (staged files only), CI pipeline (hard, non-bypassable check).

Legacy rollout

One-time reformat commit plus .git-blame-ignore-revs, or a baseline approach for risk-averse projects.

Time gained

Review comments about formatting disappear entirely, attention shifts to architecture and logic.

11. FAQ: Enforcing PSR-12 in a Team

1How can I enforce PSR-12 in a team?
Via automated tools: PHP_CodeSniffer for detection, PHP-CS-Fixer for auto-fix, backed by pre-commit hooks and CI.
2What is the difference between PHP_CodeSniffer and PHP-CS-Fixer?
PHP_CodeSniffer primarily reports, with phpcbf as a separate fixer. PHP-CS-Fixer is an auto-fixer from the ground up with broader correction coverage.
3How do I set up a pre-commit hook?
A bash script checking only staged PHP files, or tools like Captain Hook or Grumphp for declarative configuration.
4Can a pre-commit hook be bypassed?
Yes, with --no-verify. That is why CI remains the actual, non-bypassable enforcement authority.
5How do I introduce PSR-12 in legacy code?
Reformat commit plus .git-blame-ignore-revs, or a baseline approach that only blocks new violations.
6What does .git-blame-ignore-revs do?
Lists commits that git blame skips, so a reformat commit does not overwrite the original authorship.
7How do I set up format-on-save in PhpStorm?
Under Quality Tools, PHP-CS-Fixer, store the path to the executable, then enable Run on save.
8Should the style check in CI be blocking?
Yes, as a required check, otherwise PSR-12 stays a non-binding recommendation instead of an enforced rule.
9Does this replace code review completely?
No, it only removes formatting comments. Architecture, edge cases and naming remain a human review task.
10Do I need both tools at once?
Not strictly, but the combination of PHP-CS-Fixer for auto-fix and PHP_CodeSniffer for additional sniffs is common in practice.

Mironsoft

Code quality, tooling and CI pipelines for PHP teams

Still having formatting discussions in code review?

We set up PHP_CodeSniffer and PHP-CS-Fixer cleanly, integrate pre-commit hooks and CI checks, and support the rollout on your existing codebase without a risky big-bang reformat.

Tooling setup

phpcs.xml and .php-cs-fixer.php configured to fit your existing codebase

Legacy rollout

Reformat strategy with git-blame-ignore-revs or a baseline approach, implemented at low risk

CI integration

Pre-commit hooks and required checks that permanently remove style violations from review