Six repos, six pint.json files, all subtly different. One has declare_strict_types, one still has the default Laravel preset, and the oldest picked up a rule nobody remembers adding. Code review fills with style nits, and every attempt to fix it means opening six PRs. A Laravel Pint shared config solves this properly: one canonical pint.json, shipped as a Composer package, consumed by every project through --config. It takes about twenty minutes to set up, and after that a rule change is a version bump instead of a copy-paste marathon.
Define your canonical pint.json#
Start from a preset and override only what you actually disagree with. Pint ships five presets — laravel, per, psr12, symfony, and empty — and everything under rules is a PHP CS Fixer rule or one of Pint's own Pint/-prefixed rules. Keep this file small: every rule you add is a rule you are committing to enforce across every repo that installs it.
{
"preset": "laravel",
"rules": {
"declare_strict_types": true,
"fully_qualified_strict_types": true,
"simplified_null_return": true,
"new_with_parentheses": {
"anonymous_class": true,
"named_class": true
},
"ordered_imports": {
"sort_algorithm": "alpha"
}
}
}
Resist the urge to add "Pint/laravel_blade": true here on day one. It formats your Blade templates, but it does it via Prettier, which means Node.js has to be installed on every developer machine and in every CI job that consumes this package. That is a fine trade to make deliberately and a miserable one to discover from a red build.
Leave exclude, notName, and notPath out of the canonical file too. Those are almost always project-specific, and as you will see in a moment, Pint gives you no way to layer a local exclusion on top of a shared config.
Package the shared config as a Composer dev dependency#
The package is two files. Composer does not need an autoload block — there is no PHP in here, you are only shipping a JSON file to a predictable path inside vendor/.
acme-pint-config/
├── composer.json
└── pint.json
{
"name": "acme/pint-config",
"description": "Shared Laravel Pint configuration for Acme projects.",
"type": "library",
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/pint": "^1.30"
}
}
Requiring laravel/pint from the config package is the bit people skip. It stops a repo pinning an ancient Pint and quietly getting different output from the same rules — and it matters for two specific fixes: --parallel alongside --config was broken until Pint 1.27.1, and 1.29.2 hardened remote config loading. Pint refuses to install as a non-dev dependency since 1.26.0, so keep the install on the --dev side in consuming projects.
Push it to Packagist if your style is public, or to Private Packagist if it is not. For a small team, a plain VCS repository entry in the consuming project works without any registry at all:
{
"repositories": [
{
"type": "vcs",
"url": "git@github.com:acme/pint-config.git"
}
]
}
Point every project at the shared Pint config#
Install the package, then hand Pint the path to the config inside vendor/. The --config flag only changes which rules apply — Pint still scans the current working directory for .php files, so running this from your project root lints your project, not the vendored package.
composer require --dev acme/pint-config
./vendor/bin/pint --config vendor/acme/pint-config/pint.json
Verify it took effect before you roll it out anywhere. Run it with --test and -v on a repo you know is clean under the old config — if the rule set changed, Pint tells you exactly which files and rules disagree:
./vendor/bin/pint --config vendor/acme/pint-config/pint.json --test -v
Point your editor at the same path while you are here. PhpStorm's Pint integration and the VS Code Pint extension both take a config path, and if you leave them on autodetect they fall back to the project's absent pint.json and the default Laravel preset — so your editor formats one way and CI demands another.
Wire up Composer scripts so nobody types the path#
Nobody will remember vendor/acme/pint-config/pint.json. Put it in composer.json once and give the team two verbs: one that fixes, one that checks. Composer prepends vendor/bin to PATH when it runs scripts, so pint resolves without the ./vendor/bin/ prefix.
{
"scripts": {
"lint": "pint --config vendor/acme/pint-config/pint.json",
"lint:dirty": "pint --config vendor/acme/pint-config/pint.json --dirty",
"lint:test": "pint --config vendor/acme/pint-config/pint.json --test"
}
}
composer lint:dirty is the one developers actually run — --dirty restricts Pint to files with uncommitted changes, which on a large codebase is the difference between a second and a minute. If you already have a combined quality command, fold this in beside your static analysis step rather than adding a fourth thing to remember; I cover that pattern in running Pint, PHPStan and Rector from one command.
Enforce the shared config in CI#
pint --test exits non-zero when any file would change, which is all a CI gate needs. The workflow in the Laravel docs installs Pint globally via setup-php and auto-commits fixes — that will not work here, because a global Pint binary has no vendor/acme/pint-config/pint.json to read. Install dependencies first, then run the project-local binary through your Composer script.
name: Code Style
on:
pull_request:
push:
branches: [main]
jobs:
pint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0 # --diff needs history from the base branch
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Check code style
run: composer lint:test
On a big repo, swap the last step for pint --config vendor/acme/pint-config/pint.json --test --diff=origin/main so Pint only inspects files the branch actually touched — that is what the fetch-depth: 0 is for. The composer install step is now on the critical path for every style check, so cache it; caching Composer and npm in Laravel CI usually takes this job under thirty seconds.
Version the config and roll out rule changes deliberately#
Tag the config package with semver and treat any rule addition as a breaking change, because that is what it is: the next composer update in a consuming repo turns a green build red. Consuming projects pin a major ("acme/pint-config": "^2.0"), so a new rule never arrives by surprise.
# In the config package
git tag v2.0.0 && git push --tags
# In a consuming project, on its own branch
composer require --dev acme/pint-config:^2.0
composer lint
Commit the reformat on its own, separate from any feature work, and then keep it out of git blame:
# .git-blame-ignore-revs
# Reformat under acme/pint-config v2.0.0
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
git config blame.ignoreRevsFile .git-blame-ignore-revs
GitHub reads that file automatically; the git config line makes local git blame skip it too. This is the same staged-rollout discipline that makes burning down a PHPStan baseline survivable — introduce the strictness centrally, absorb it repo by repo, never in the same PR as a behaviour change.
Ship it and handle the edge cases#
You now have one canonical pint.json in a versioned Composer package, a --config flag pointing every repo at it, Composer scripts so nobody types that path, a CI gate that fails on drift, and a rollout process. Before you push this to the whole estate, walk the sharp edges.
There is no extends, and configs do not merge. This is the big one. Pint reads exactly one config file; a repo cannot layer its own exclude on top of yours. The feature request covering both approaches — an extends key, or repeated --config arguments merged in order — was opened as issue #174 and closed without being implemented. The workaround I use is inversion: instead of excluding directories in the config, name the directories you want linted as arguments, composer lint -- app config database routes tests. It keeps the shared config free of project-specific paths.
Passing explicit files bypasses exclude and notPath. Issue #105 documents this, and it bites hardest in Git hooks that feed the staged file list to Pint — files you deliberately excluded get reformatted anyway. If you run Pint from a hook, prefer --dirty over enumerating files; the setup is in running Pint on a pre-commit hook.
--config accepts a URL, but I would not. pint --config https://acme.dev/pint.json works, though it is undocumented, and 1.29.2 tightened it to reject plain http://. It is tempting because there is nothing to install — and it means your linter now depends on a web server being reachable, with no lockfile and no version pin. The Composer package gives you both.
vendor/ does not exist on a fresh clone. Any step that runs Pint before composer install fails with a missing-config error rather than a style error, which is a confusing five minutes for whoever hits it. Order your CI steps and your editor tooling accordingly.
Once the config package is live, the natural next move is folding it into the rest of your quality tooling so a single command covers style, static analysis, and automated refactors — my current Laravel developer toolchain lays out how those pieces fit, and Rector for automated Laravel upgrades is the tool that pairs with Pint when a rule change needs real code edits rather than reformatting.
FAQ#
How do I share a Pint config across projects?
Put the canonical pint.json in a small Composer package, install it in each project with composer require --dev acme/pint-config, and run Pint with --config vendor/acme/pint-config/pint.json. Wrap that command in a Composer script so nobody has to remember the path. Updating the style everywhere then becomes a version bump in each repo rather than an edit to each repo's config file.
Can Laravel Pint extend a shared config?
No. Pint reads exactly one configuration file and has no extends key, and passing multiple --config arguments does not merge them. The feature request covering both options was raised as issue #174 and closed without implementation. In practice this means a consuming project cannot add its own exclude or notPath entries on top of a shared config — keep those out of the shared file and pass the directories you want linted as command-line arguments instead.
How do I set custom rules in pint.json?
Add a rules object alongside your preset and set each rule to true, false, or a configuration object. Pint is built on PHP CS Fixer, so any PHP CS Fixer rule name is valid, and the PHP CS Fixer Configurator is the reference for what each one accepts. Pint also ships its own rules prefixed with Pint/, such as Pint/laravel_blade and Pint/phpdoc_type_annotations_only, which are off by default and must be enabled explicitly.
How do I enforce Pint formatting in CI?
Run pint --test, which inspects files without changing them and exits with a non-zero code if any style errors are found — enough on its own to fail the job. With a shared config you must run composer install before the check so the config exists inside vendor/, and you should invoke the project-local binary rather than a globally installed Pint. On large repositories add --diff=origin/main and check out with full history so Pint only inspects files the branch changed.
Where should a shared Pint config live?
In its own repository, published as a Composer package and installed as a dev dependency, so every project gets it at a predictable path under vendor/ and pins a specific version through composer.lock. Packagist works for public styles; Private Packagist or a plain VCS repository entry in composer.json covers private ones. Hosting the file on a web server and pointing --config at the URL also works, but you lose version pinning and add a network dependency to your linter.