{"id":691,"date":"2026-09-16T13:01:25","date_gmt":"2026-09-16T13:01:25","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-pint-the-one-commit-that-broke-my-git-blame\/"},"modified":"2026-09-16T13:01:25","modified_gmt":"2026-09-16T13:01:25","slug":"laravel-pint-the-one-commit-that-broke-my-git-blame","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-pint-the-one-commit-that-broke-my-git-blame\/","title":{"rendered":"Laravel Pint: The One Commit That Broke My Git Blame"},"content":{"rendered":"<p>Short version for the impatient: run Laravel Pint once, in its own commit, add that commit hash to a <code>.git-blame-ignore-revs<\/code> file, and put <code>pint --test<\/code> in CI instead of the auto-commit bot the docs suggest. If you want to know why I&rsquo;m so specific about this, it&rsquo;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.<\/p>\n<p>The app was a four-year-old Laravel project I&rsquo;d inherited. Three previous developers, three coding styles, a <code>.php_cs<\/code> 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&rsquo;t fine was that I committed it alongside a bug fix, and from that moment <code>git blame<\/code> on any file pointed at me. The one tool you reach for when you need to ask &ldquo;who wrote this and why&rdquo; was gone.<\/p>\n<p>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.<\/p>\n<h2 id=\"what-pint-is-and-isnt\">What Pint is and isn&rsquo;t<\/h2>\n<p><a href=\"https:\/\/laravel.com\/docs\/13.x\/pint\" rel=\"nofollow noopener\" target=\"_blank\">Laravel Pint<\/a> is a wrapper around <a href=\"https:\/\/github.com\/PHP-CS-Fixer\/PHP-CS-Fixer\" rel=\"nofollow noopener\" target=\"_blank\">PHP CS Fixer<\/a> with an opinionated default rule set. It ships with new Laravel apps, so you probably already have it in <code>vendor\/bin<\/code>. If you don&rsquo;t, it&rsquo;s one Composer line:<\/p>\n<pre><code class=\"language-bash\">composer require laravel\/pint --dev\n.\/vendor\/bin\/pint\n<\/code><\/pre>\n<p>With no config at all it applies the <code>laravel<\/code> preset. There are four others (<code>per<\/code>, <code>psr12<\/code>, <code>symfony<\/code>, and <code>empty<\/code>), and you can override individual PHP CS Fixer rules in a <code>pint.json<\/code> file at the project root. That is the entire configuration surface. I&rsquo;ve never needed more than this:<\/p>\n<pre><code class=\"language-json\">{\n    &quot;preset&quot;: &quot;laravel&quot;,\n    &quot;rules&quot;: {\n        &quot;simplified_null_return&quot;: true,\n        &quot;concat_space&quot;: {\n            &quot;spacing&quot;: &quot;one&quot;\n        }\n    },\n    &quot;exclude&quot;: [\n        &quot;storage&quot;,\n        &quot;bootstrap\/cache&quot;\n    ]\n}\n<\/code><\/pre>\n<p>What Pint isn&rsquo;t: a linter. It won&rsquo;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&rsquo;s PHPStan or a language server, and I wrote about <a href=\"https:\/\/abrarqasim.com\/blog\/symfony-lsp-check-the-route-typo-phpstan-cant-see\" rel=\"noopener\">the route typo PHPStan can&rsquo;t see<\/a> if you&rsquo;re weighing those up. Pint and static analysis are different tools and I&rsquo;d run both.<\/p>\n<p>Here&rsquo;s the kind of thing Pint changes, using a method I pulled from that inherited codebase (names changed):<\/p>\n<pre><code class=\"language-php\">&lt;?php\nnamespace App\\Services;\nuse App\\Models\\Order;\nuse Illuminate\\Support\\Collection;\n\nclass OrderTotals {\n    public function __construct( private Collection $orders ){}\n\n    public function unpaid() : Collection\n    {\n        return $this-&gt;orders-&gt;filter(function($o){\n            return $o-&gt;status==&quot;unpaid&quot;;\n        })-&gt;values();\n    }\n}\n<\/code><\/pre>\n<p>After one run with the <code>laravel<\/code> preset:<\/p>\n<pre><code class=\"language-php\">&lt;?php\n\nnamespace App\\Services;\n\nuse App\\Models\\Order;\nuse Illuminate\\Support\\Collection;\n\nclass OrderTotals\n{\n    public function __construct(private Collection $orders) {}\n\n    public function unpaid(): Collection\n    {\n        return $this-&gt;orders-&gt;filter(function ($o) {\n            return $o-&gt;status == 'unpaid';\n        })-&gt;values();\n    }\n}\n<\/code><\/pre>\n<p>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.<\/p>\n<h2 id=\"the-one-commit-rule\">The one-commit rule<\/h2>\n<p>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&rsquo;t. It&rsquo;s a change with zero semantic content that happens to rewrite a huge number of lines, and it deserves to be isolated.<\/p>\n<p>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:<\/p>\n<pre><code class=\"language-bash\">git checkout -b chore\/adopt-pint\n.\/vendor\/bin\/pint\ngit add -A\ngit commit -m &quot;chore: apply Laravel Pint to the whole codebase (no functional changes)&quot;\n<\/code><\/pre>\n<p>Then, and this is the part I didn&rsquo;t know existed, you tell Git to skip that commit when computing blame. Git has supported an <code>--ignore-revs-file<\/code> option on <code>git blame<\/code> since 2.23, and the <a href=\"https:\/\/git-scm.com\/docs\/git-blame\" rel=\"nofollow noopener\" target=\"_blank\">git-blame docs<\/a> describe the <code>blame.ignoreRevsFile<\/code> config that makes it automatic. The convention is a file called <code>.git-blame-ignore-revs<\/code> at the repo root:<\/p>\n<pre><code class=\"language-text\"># Laravel Pint adoption, whole-repo formatting, no functional changes\na91c4e7d0b2f6e1c3d5a8b9f0e2d4c6a8b0f1e3d\n<\/code><\/pre>\n<p>Commit that file, then set the config once per clone:<\/p>\n<pre><code class=\"language-bash\">git config blame.ignoreRevsFile .git-blame-ignore-revs\n<\/code><\/pre>\n<p><a href=\"https:\/\/docs.github.com\/en\/repositories\/working-with-files\/using-files\/viewing-and-understanding-files#ignore-commits-in-the-blame-view\" rel=\"nofollow noopener\" target=\"_blank\">GitHub&rsquo;s blame view<\/a> reads the same file automatically, so the web UI skips the formatting commit too. VS Code&rsquo;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.<\/p>\n<p>I&rsquo;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&rsquo;d known. Now you know.<\/p>\n<h2 id=\"ci-why-i-dont-use-the-auto-commit-action\">CI: why I don&rsquo;t use the auto-commit action<\/h2>\n<p>The Laravel docs give you a GitHub Actions workflow that runs Pint on every push and then uses <code>stefanzweifel\/git-auto-commit-action<\/code> to commit whatever it changed back to your branch. I tried it for about a week and turned it off.<\/p>\n<p>The problem is that a bot is pushing commits to the branch you&rsquo;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&rsquo;s a mild annoyance. With two people on the same feature branch it&rsquo;s a small daily fight. And it hides the underlying issue, which is that somebody&rsquo;s editor isn&rsquo;t formatting on save.<\/p>\n<p>What I run instead is a check, not a fix:<\/p>\n<pre><code class=\"language-yaml\">name: Code style\n\non: [pull_request]\n\njobs:\n  pint:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v5\n        with:\n          fetch-depth: 0\n\n      - uses: shivammathur\/setup-php@v2\n        with:\n          php-version: &quot;8.4&quot;\n          tools: pint\n\n      - name: Pint (test mode, changed files only)\n        run: pint --test --diff=origin\/main\n<\/code><\/pre>\n<p>Two flags doing the work. <code>--test<\/code> makes Pint exit non-zero if anything would change, without changing it. <code>--diff=origin\/main<\/code> restricts the check to files that differ from <code>main<\/code>, so a 900-file project doesn&rsquo;t get fully re-scanned on every pull request. The <code>fetch-depth: 0<\/code> is required because <code>--diff<\/code> 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&rsquo;ve written about <a href=\"https:\/\/abrarqasim.com\/blog\/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times\" rel=\"noopener\">reusable GitHub Actions workflows and the bug I kept reintroducing<\/a>, which is where this job now lives for me.<\/p>\n<p>There&rsquo;s a middle option, <code>--repair<\/code>, 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 <code>--test<\/code>.<\/p>\n<h2 id=\"the-pre-commit-hook-that-makes-ci-boring\">The pre-commit hook that makes CI boring<\/h2>\n<p>If CI only ever fails on style, you&rsquo;ve moved the problem somewhere slower and more public. The fix is to catch it before the commit exists. Pint has a <code>--dirty<\/code> flag that limits it to files with uncommitted changes according to Git, which is exactly what a pre-commit hook wants:<\/p>\n<pre><code class=\"language-bash\">#!\/bin\/sh\n# .git\/hooks\/pre-commit (or wire it through your hook manager of choice)\n.\/vendor\/bin\/pint --dirty --repair\nif [ $? -ne 0 ]; then\n    echo &quot;Pint reformatted staged files. Review and re-stage them.&quot;\n    exit 1\nfi\n<\/code><\/pre>\n<p>The <code>--repair<\/code> 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&rsquo;t like it; a commit that contains lines I never saw is a commit I can&rsquo;t vouch for.<\/p>\n<p>On the 640-file project, <code>--dirty<\/code> runs in well under a second because it&rsquo;s only looking at whatever I touched. Full runs on the same codebase take a few seconds, or less with <code>--parallel<\/code>, which the docs mark as experimental and which I&rsquo;ve had no trouble with on a laptop. You can cap it with <code>--max-processes=4<\/code> if it&rsquo;s eating a CI runner alive.<\/p>\n<h2 id=\"two-rules-id-think-twice-about\">Two rules I&rsquo;d think twice about<\/h2>\n<p>Pint ships a couple of custom rules under the <code>Pint\/<\/code> prefix that are off by default. I turned both on, then turned one off.<\/p>\n<p><code>Pint\/laravel_blade<\/code> formats your <code>.blade.php<\/code> 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&rsquo;s an extra setup step the PHP-only workflow above doesn&rsquo;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.<\/p>\n<p><code>Pint\/phpdoc_type_annotations_only<\/code> is the one I backed out of. It strips every comment that doesn&rsquo;t contain an <code>@<\/code> 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 (<code>@note<\/code> 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 <code>config<\/code> directory is skipped automatically, which tells you the maintainers hit the same problem.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>If you have a Laravel project that doesn&rsquo;t run Pint yet, here&rsquo;s the order I&rsquo;d do it in, and it fits in an afternoon.<\/p>\n<p>Create a branch. Run <code>.\/vendor\/bin\/pint<\/code> with no config and commit the result on its own. Add the hash to <code>.git-blame-ignore-revs<\/code>, commit that, and run <code>git config blame.ignoreRevsFile .git-blame-ignore-revs<\/code>. Open a file you know was written by someone else and check that blame still says so. Then add the <code>--test --diff=origin\/main<\/code> job to CI and the <code>--dirty --repair<\/code> pre-commit hook, and merge.<\/p>\n<p>Leave <code>pint.json<\/code> alone until the default preset annoys you about something specific. Mine has two rule overrides after a year, and I&rsquo;m not sure the second one was worth the argument.<\/p>\n<p>I&rsquo;ve been doing this on every Laravel codebase I take over, and it&rsquo;s become the first commit in every engagement listed on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>. Formatting isn&rsquo;t exciting. It is the cheapest way I know to make a stranger&rsquo;s code feel like mine before I start changing it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I adopted Laravel Pint on a 640-file legacy app and wrecked git blame. Here&#8217;s the one-commit fix, .git-blame-ignore-revs, and the CI check I run instead of the bot.<\/p>\n","protected":false},"author":2,"featured_media":690,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I adopted Laravel Pint on a 640-file legacy app and wrecked git blame. Here's the one-commit fix, .git-blame-ignore-revs, and the CI check I run instead of the bot.","rank_math_focus_keyword":"laravel pint","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[173,52],"tags":[769,770,708,56,53,768],"class_list":["post-691","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel","category-php","tag-code-style","tag-git","tag-github-actions","tag-laravel","tag-php","tag-pint"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/691","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=691"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/691\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/690"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=691"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=691"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=691"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}