Short version for the impatient: run Laravel Pint once, in its own commit, add that commit hash to a .git-blame-ignore-revs file, and put pint --test in CI instead of the auto-commit bot the docs suggest. If you want to know why I'm so specific about this, it's because I did it the other way first and spent a Thursday afternoon explaining to a client why every line in their codebase now said I wrote it.
The app was a four-year-old Laravel project I'd inherited. Three previous developers, three coding styles, a .php_cs file from 2021 that nobody had run since, and a README that described a different app. I added Pint, ran it, and it rewrote 640 files. That part was fine. What wasn't fine was that I committed it alongside a bug fix, and from that moment git blame on any file pointed at me. The one tool you reach for when you need to ask "who wrote this and why" was gone.
So this post is about the boring parts of adopting Pint on a real project. The formatter itself takes thirty seconds to learn. The stuff around it took me a week to get right.
What Pint is and isn't
Laravel Pint is a wrapper around PHP CS Fixer with an opinionated default rule set. It ships with new Laravel apps, so you probably already have it in vendor/bin. If you don't, it's one Composer line:
composer require laravel/pint --dev
./vendor/bin/pint
With no config at all it applies the laravel preset. There are four others (per, psr12, symfony, and empty), and you can override individual PHP CS Fixer rules in a pint.json file at the project root. That is the entire configuration surface. I've never needed more than this:
{
"preset": "laravel",
"rules": {
"simplified_null_return": true,
"concat_space": {
"spacing": "one"
}
},
"exclude": [
"storage",
"bootstrap/cache"
]
}
What Pint isn't: a linter. It won't tell you a variable is unused or a method returns the wrong type. It only cares about whitespace, braces, imports, and the handful of syntax rewrites PHP CS Fixer knows how to do safely. If you want the bug-catching half of the story, that's PHPStan or a language server, and I wrote about the route typo PHPStan can't see if you're weighing those up. Pint and static analysis are different tools and I'd run both.
Here's the kind of thing Pint changes, using a method I pulled from that inherited codebase (names changed):
<?php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Collection;
class OrderTotals {
public function __construct( private Collection $orders ){}
public function unpaid() : Collection
{
return $this->orders->filter(function($o){
return $o->status=="unpaid";
})->values();
}
}
After one run with the laravel preset:
<?php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Collection;
class OrderTotals
{
public function __construct(private Collection $orders) {}
public function unpaid(): Collection
{
return $this->orders->filter(function ($o) {
return $o->status == 'unpaid';
})->values();
}
}
Blank line after the opening tag, brace on its own line for the class, spaces around the closure parameters, single quotes. None of it changes behaviour. All of it changes the diff. Multiply that by 640 files and you see the blame problem coming.
The one-commit rule
When you adopt a formatter on an existing project, the first run is going to touch most of the repository. The mistake I made was treating that as a normal change. It isn't. It's a change with zero semantic content that happens to rewrite a huge number of lines, and it deserves to be isolated.
My rule now is that the formatting run gets its own commit, with nothing else in it, and a message that says exactly what it is:
git checkout -b chore/adopt-pint
./vendor/bin/pint
git add -A
git commit -m "chore: apply Laravel Pint to the whole codebase (no functional changes)"
Then, and this is the part I didn't know existed, you tell Git to skip that commit when computing blame. Git has supported an --ignore-revs-file option on git blame since 2.23, and the git-blame docs describe the blame.ignoreRevsFile config that makes it automatic. The convention is a file called .git-blame-ignore-revs at the repo root:
# Laravel Pint adoption, whole-repo formatting, no functional changes
a91c4e7d0b2f6e1c3d5a8b9f0e2d4c6a8b0f1e3d
Commit that file, then set the config once per clone:
git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub's blame view reads the same file automatically, so the web UI skips the formatting commit too. VS Code's GitLens respects the config setting. After this, blame on any line points at whoever wrote the line before Pint reindented it, which is what you wanted all along.
I'd already merged my version by the time I learned about this, so I had to do the annoying thing and revert the merge, split the commit in two, and redo it. Twenty minutes of work that would have been two if I'd known. Now you know.
CI: why I don't use the auto-commit action
The Laravel docs give you a GitHub Actions workflow that runs Pint on every push and then uses stefanzweifel/git-auto-commit-action to commit whatever it changed back to your branch. I tried it for about a week and turned it off.
The problem is that a bot is pushing commits to the branch you're working on. You push, the bot pushes a fix-up, you try to push again and get rejected because your local branch is behind. On a solo project it's a mild annoyance. With two people on the same feature branch it's a small daily fight. And it hides the underlying issue, which is that somebody's editor isn't formatting on save.
What I run instead is a check, not a fix:
name: Code style
on: [pull_request]
jobs:
pint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
tools: pint
- name: Pint (test mode, changed files only)
run: pint --test --diff=origin/main
Two flags doing the work. --test makes Pint exit non-zero if anything would change, without changing it. --diff=origin/main restricts the check to files that differ from main, so a 900-file project doesn't get fully re-scanned on every pull request. The fetch-depth: 0 is required because --diff needs the branch history to compare against, and I lost twenty minutes to a red build before I worked that out. If you keep a lot of workflows like this one, I've written about reusable GitHub Actions workflows and the bug I kept reintroducing, which is where this job now lives for me.
There's a middle option, --repair, which fixes the files and still exits non-zero. I use it locally, never in CI, because a CI job that mutates files it then throws away is just a slower --test.
The pre-commit hook that makes CI boring
If CI only ever fails on style, you've moved the problem somewhere slower and more public. The fix is to catch it before the commit exists. Pint has a --dirty flag that limits it to files with uncommitted changes according to Git, which is exactly what a pre-commit hook wants:
#!/bin/sh
# .git/hooks/pre-commit (or wire it through your hook manager of choice)
./vendor/bin/pint --dirty --repair
if [ $? -ne 0 ]; then
echo "Pint reformatted staged files. Review and re-stage them."
exit 1
fi
The --repair there is deliberate. It fixes the files and then fails the commit, so you look at what changed before it goes in. I tried the version that silently re-stages the fixed files and I didn't like it; a commit that contains lines I never saw is a commit I can't vouch for.
On the 640-file project, --dirty runs in well under a second because it's only looking at whatever I touched. Full runs on the same codebase take a few seconds, or less with --parallel, which the docs mark as experimental and which I've had no trouble with on a laptop. You can cap it with --max-processes=4 if it's eating a CI runner alive.
Two rules I'd think twice about
Pint ships a couple of custom rules under the Pint/ prefix that are off by default. I turned both on, then turned one off.
Pint/laravel_blade formats your .blade.php files. Under the hood it shells out to Prettier with the Blade and Tailwind plugins, which means Node has to be installed wherever Pint runs, including your CI runner. That's an extra setup step the PHP-only workflow above doesn't have. I kept the rule on because the Blade templates on that project were the ugliest part of it, but I had to add a Node setup step to the workflow, and I want you to know that before you enable it and wonder why CI broke.
Pint/phpdoc_type_annotations_only is the one I backed out of. It strips every comment that doesn't contain an @ annotation. The docs are honest about this: single-line and block comments without annotations are removed entirely, and you keep a comment only by prefixing it with one of the three annotation tags the docs list (@note is the one I ended up using). On a fresh project where you control every comment, fine. On an inherited codebase, I watched it delete a comment that said why a retry loop had a 3-second sleep in it, and that comment was the only documentation the retry had. I restored the file and removed the rule. If you want it, run it once on a branch and read the diff before you commit. The config directory is skipped automatically, which tells you the maintainers hit the same problem.
What I'd do this week
If you have a Laravel project that doesn't run Pint yet, here's the order I'd do it in, and it fits in an afternoon.
Create a branch. Run ./vendor/bin/pint with no config and commit the result on its own. Add the hash to .git-blame-ignore-revs, commit that, and run git config blame.ignoreRevsFile .git-blame-ignore-revs. Open a file you know was written by someone else and check that blame still says so. Then add the --test --diff=origin/main job to CI and the --dirty --repair pre-commit hook, and merge.
Leave pint.json alone until the default preset annoys you about something specific. Mine has two rule overrides after a year, and I'm not sure the second one was worth the argument.
I've been doing this on every Laravel codebase I take over, and it's become the first commit in every engagement listed on my portfolio. Formatting isn't exciting. It is the cheapest way I know to make a stranger's code feel like mine before I start changing it.
Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.
Top comments (0)