DEV Community

Cover image for Laravel Pint vs PHP-CS-Fixer: Why I Stopped Configuring My Linter
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel Pint vs PHP-CS-Fixer: Why I Stopped Configuring My Linter


You've seen this PR. Eighteen comments, zero of them about logic. Someone wants trailing commas. Someone else wants short array syntax even though it's been the default since 2014. The actual bug fix gets approved six days later, after the style debate burns itself out.

This is what .php-cs-fixer.dist.php files look like in the wild. 200 lines of rule tuning, a @PER-CS2.0 base, three custom fixers, and a risky toggle that nobody on the team remembers enabling. The file outlives the people who wrote it.

Laravel Pint exists because someone at Laravel got tired of that file. It's a wrapper around PHP-CS-Fixer with a curated rule set and three presets. You install it, you run vendor/bin/pint, your code is formatted. There's no config to argue about because the config is the preset.

Teams that switch to Pint usually report the same thing: style PRs drop from a weekly ritual to almost never. That's the whole post, but you probably want the reasoning.

What Pint actually is

Pint is friendsofphp/php-cs-fixer plus an opinionated rule set plus a slim CLI. That's it. The package is laravel/pint, it ships with new Laravel apps since 9.x, and it works on any PHP 8.1+ project. Symfony, raw PHP, a CodeIgniter app from 2018, doesn't matter. The Laravel in the name is misleading. Symfony folks should care about Pint too.

The internals matter for one reason: when Pint complains about something, the error message says "PHP-CS-Fixer" because that's what's underneath. If you Google the rule name (no_unused_imports, binary_operator_spaces, single_quote), you land on PHP-CS-Fixer docs. Pint inherits the entire ruleset, it just picks a curated subset and sets defaults.

The three presets

Pint ships three presets. You pick one in pint.json at the repo root:

{
    "preset": "laravel"
}
Enter fullscreen mode Exit fullscreen mode

That's the whole config for most projects. The three options:

  • laravel: the default. Laravel's house style. Single quotes, short array syntax, ordered imports, no unused imports, trailing commas in multiline arrays, PSR-12 base with Laravel-flavored additions.
  • psr12: strict PSR-12. Use this if you're on a non-Laravel codebase and your CI lint already enforces PSR-12.
  • symfony: Symfony's house style. Slightly different bracket placement, different ordering rules. Use this on Symfony projects.

There's no fourth. There's no "google" preset. There's no "my-team-likes-Yoda-conditions" preset. That's deliberate. You pick one of three and you stop talking about it.

When you'd actually configure rules

Sometimes you need to deviate. A real pint.json with overrides looks like this:

{
    "preset": "laravel",
    "rules": {
        "concat_space": {
            "spacing": "one"
        },
        "method_chaining_indentation": true,
        "no_unused_imports": true,
        "ordered_imports": {
            "sort_algorithm": "alpha",
            "imports_order": ["class", "function", "const"]
        },
        "single_quote": true,
        "trailing_comma_in_multiline": {
            "elements": ["arrays", "arguments", "parameters"]
        }
    },
    "exclude": [
        "database/migrations",
        "storage"
    ]
}
Enter fullscreen mode Exit fullscreen mode

That's a reasonable override file. Notice what's there: concat spacing (one space around .), import ordering, exclude folders that have generated code. Notice what's not there: 47 rules copied from a Stack Overflow answer.

The honest rule of thumb: if you can't explain in one sentence why a rule overrides the preset, don't override it. "I prefer it" is not a sentence. "Our Doctrine migrations break with reordered imports" is a sentence.

The migrations exclude is the one most teams need. Laravel migrations have generated structure that gets reformatted in ways that produce useless diffs. Same with storage/ if you store generated files there.

The CI step

Add Pint to GitHub Actions. This goes in .github/workflows/lint.yml:

name: Lint

on:
  pull_request:
  push:
    branches: [main]

jobs:
  pint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: none

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}

      - name: Install dependencies
        run: composer install --prefer-dist --no-interaction --no-progress

      - name: Run Pint
        run: vendor/bin/pint --test
Enter fullscreen mode Exit fullscreen mode

The --test flag is what you want in CI. It exits non-zero if anything would change, but doesn't modify files. The PR fails, the dev runs vendor/bin/pint locally, commits, push, green.

The Composer cache step matters more than you'd think. Without it, every PR waits 40 seconds for composer install. With it, Pint runs in about 6 seconds on a Laravel 12 app with 80 packages.

If you want CI to auto-fix and commit, that exists too. There's aglipanci/laravel-pint-action@v2 that does it. Don't do it. Auto-commit from CI feels clean until it stomps on a developer's local commit and triggers a merge conflict storm. Let humans run pint locally.

Pre-commit hook with Lefthook

The CI step catches bad commits. A pre-commit hook prevents them. Pint runs in under a second on changed files, so there's no excuse to skip the hook.

Lefthook beats Husky for PHP projects. It's a single Go binary, no Node dependency, and the config is YAML. Install with brew install lefthook or grab the binary. Then lefthook.yml at the repo root:

pre-commit:
  parallel: true
  commands:
    pint:
      glob: "*.php"
      run: vendor/bin/pint {staged_files}
      stage_fixed: true
Enter fullscreen mode Exit fullscreen mode

The stage_fixed: true is the magic. Pint fixes the file, Lefthook re-stages it, the commit goes through. No "you have unstaged changes" loop, no manual re-add.

Then lefthook install once per clone, and every commit gets linted. If you're on a team where some devs use Husky and some don't, lefthook install is one shell command they run after composer install. That's the friction budget.

For the Husky version (if you're stuck with Node tooling), the .husky/pre-commit looks like:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

vendor/bin/pint --test
Enter fullscreen mode Exit fullscreen mode

This is the lazy version. It fails the commit instead of fixing it. Works, but the dev has to run pint themselves and re-stage. Lefthook with stage_fixed: true is the better UX.

When PHP-CS-Fixer raw still wins

Pint is the right answer for most projects. But there are real cases where you go back to PHP-CS-Fixer directly:

  • You need a rule Pint doesn't expose. Pint covers most rules, but PHP-CS-Fixer has fixers in experimental and risky categories that Pint defaults to off. If you need php_unit_test_class_requires_covers enforced or one of the more aggressive risky fixers, configure PHP-CS-Fixer directly.
  • You're on a non-Laravel project with an existing PHP-CS-Fixer config. If you have a working 300-line .php-cs-fixer.dist.php and a team that's used to it, don't migrate for the sake of it. The cost of the migration outweighs the benefit.
  • You have hard style requirements from an SDK or contract. Some companies require PSR-12 + extras as a deliverable. If your contract says "PHP-CS-Fixer with this config", that's what you run.

For everything else (new Laravel projects, new Symfony projects, internal tools, side projects), Pint with one of three presets is the answer.

The cultural part

Plain version, because the rest of the post is technical and this part isn't. Style debates are the most expensive cheap thing your team does. They feel productive. They are not productive.

Every hour spent in a meeting arguing about whether you allow array() or only [] is an hour not building. Every PR comment about a trailing comma is a comment that should have been about the bug. Every team standard that takes 90 minutes to document is 90 minutes of senior time that produced zero customer value.

Pint short-circuits all of that. The preset is the answer. The answer is the preset. When someone asks "why do we use single quotes?", the answer is "because the preset says so." When someone asks "why do we order imports alphabetically?", same answer. The decision is offloaded to a tool. The team makes one decision once ("we use Pint with preset X") and then nobody talks about style ever again.

The honest counterargument: some teams genuinely do have style preferences that conflict with all three presets. Some teams have an internal tradition they care about. Some teams find Pint's choices ugly. All of that's valid. But weigh the ongoing cost. The team that ships consistent code in five years is the team that stopped tuning style configs in year one.

The honest team answer to "can we propose style rule changes?" is "the rule is, we don't change the rules." That sounds harsh. It's also why teams that adopt it ship on time.

Pick a preset. Wire the hook. Add the CI step. Stop debating.

What's the worst style-debate PR thread you've sat through, and would Pint have killed it?


If this was useful

Pint is the layer where you stop arguing about how code looks. The next layer is where you stop arguing about where it lives: what's a service, what's a use case, what belongs in the framework and what doesn't. That's what Decoupled PHP covers: the architectural patterns your codebase reaches for after it outgrows the framework defaults. Hexagonal, clean, the boundaries that survive a framework version bump.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)