waynestate / pinstripe
The coding standard for the Wayne State app stack: shared Pint and PHPStan presets.
Requires
- php: ^8.3
- laravel/pint: ^1.32
- nette/neon: ^3.4
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.3
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/framework: ^11 || ^12 || ^13
- phpunit/phpunit: ^12
- rector/rector: ^2.8
Suggests
- larastan/larastan: ^3.13 (Laravel apps: include phpstan/laravel.neon)
- rector/rector: ^2.8 (apps: use rector/base.php from your rector.php)
Provides
None
Conflicts
None
Replaces
None
README
Pinstripe
Sharp code. Every line.
The coding standard for the Wayne State app stack: shared presets for Pint, PHPStan (with Larastan for Laravel), Rector, and ESLint with Prettier. Your app points at them and adds only its own exceptions. Pinstripe is to code what a good suit is to a job interview: nobody notices it, everybody notices when it's missing.
Not to be confused with: Pinstripe is the code style settings you run locally; Preflight runs them, and PROCTOR, in CI. Pinstripe's Rector set modernises code within the app's current PHP version, while Booster upgrades the app to a newer PHP version.
Checks: composer ci (the same command runs on GitLab and GitHub).
Documentation: the docs site is at site/docs/index.html, built from docs/ by Squire's shared builder and kept current by composer ci.
Where the code lives
The canonical repository is on nougat: enterprise-apps/pinstripe. git.wayne.edu and GitHub (waynestate/pinstripe) are read-only mirrors of it. Open issues and merge requests on nougat, not on the mirrors. This moves to git.wayne.edu once it has CI runners. The pinstripes are sewn on nougat; the mirrors are off-the-rack copies.
Front page: site/index.html, a static tailor's shop you can open in a browser or serve from site/.
On Citadel it is served by the raw-PHP deploy layer in deploy/: a Squire kit whose front controller serves site/ behind HELMET's security headers. For local development, keep serving site/ directly.
Use it in a raw PHP app
Add Pinstripe as a dev dependency:
composer config allow-plugins.phpstan/extension-installer true
composer require --dev waynestate/pinstripe:^0.1
Pinstripe loads its PHPStan rules through phpstan/extension-installer, and Composer will not ask when it cannot prompt.
Pinstripe 0.1.3 is on Packagist, and the npm package, @waynestate/pinstripe, is at 0.1.2.
Point PHPStan at the raw config. phpstan.neon:
includes: - vendor/waynestate/pinstripe/phpstan/raw.neon parameters: paths: - app
Add Composer scripts that run on every OS:
{
"scripts": {
"lint": "pint --test --config vendor/waynestate/pinstripe/pint/per.json",
"fix": "pint --config vendor/waynestate/pinstripe/pint/per.json",
"analyse": "phpstan analyse"
}
}
Show Pint findings in the merge request
Add a second script that writes the report to a file:
{
"scripts": {
"lint:gitlab": "pint --test --config vendor/waynestate/pinstripe/pint/per.json --format=gitlab > pint-codequality.json"
}
}
Point GitLab at the artifact:
artifacts: reports: codequality: pint-codequality.json
--format=gitlab writes GitLab's Code Quality JSON and [] when the code is clean, and Pinstripe tests both. Chalk marks wash out; these findings stay pinned to the merge request.
composer lint fails on style. composer fix fixes it. Pint's per preset is the PER Coding Style, and tests/fixtures/per keeps a clean and a messy proof.
composer analyse runs PHPStan at level 8, with deprecation checks too.
Pinstripe ships phpstan/phpstan-deprecation-rules, loaded automatically by
phpstan/extension-installer. Calling a @deprecated method fails the build.
The tailor flags the old cut before the fabric is retired.
Require strict types
Opt-in. Point composer lint and composer fix at the strict preset instead of per.json:
{
"scripts": {
"lint": "pint --test --config vendor/waynestate/pinstripe/pint/per-strict.json",
"fix": "pint --config vendor/waynestate/pinstripe/pint/per-strict.json"
}
}
composer fix adds declare(strict_types=1); to every file it touches, so run your tests first and commit the change on its own. For a big app, Rector's SafeDeclareStrictTypesRector is the careful route. Strict types leave no loose threads.
Use it in a Laravel app
Same, with the Laravel parts:
composer config allow-plugins.phpstan/extension-installer true
composer require --dev waynestate/pinstripe:^0.1 larastan/larastan
Pinstripe loads its PHPStan rules through phpstan/extension-installer, and Composer will not ask when it cannot prompt.
Pinstripe pulls in phpstan/extension-installer, which registers Larastan for you. When Composer asks, allow the
plugin (config.allow-plugins in your composer.json). Your phpstan.neon includes
vendor/waynestate/pinstripe/phpstan/laravel.neon and nothing else: there is no Larastan line to add, and the
scripts point at vendor/waynestate/pinstripe/pint/laravel.json.
The Laravel preset also rejects env() outside config/: once the config is cached, env() returns null, so
read config with config() instead. The rule is Larastan's, and phpstan/laravel.neon pins it on. The
environment is not a hand-me-down: take the config out of the wardrobe.
Proven on Laravel: composer ci lints a minimal Laravel 13 app (tests/fixtures/laravel-app) with
pint/laravel.json and analyzes it with phpstan/laravel.neon on every run, and fails if the app stops
passing. A smaller fixture (tests/fixtures/laravel) proves the preset really loads Larastan: it plants an
undefined Eloquent property that only Larastan reports, and a clean twin it leaves alone. The combined fixture
tests/fixtures/laravel-vue is one app tree with both PHP and Vue files, with resources/js beside app/.
composer ci lints its PHP side with Pint and PHPStan and its Vue side with ESLint and Prettier on every run,
with one planted PHP error and two planted Vue defects that each preset must report.
Use it for Vue
Add Pinstripe as a dev dependency:
npm install --save-dev @waynestate/pinstripe eslint prettier
Create eslint.config.js and spread the preset. For TypeScript:
import pinstripe from '@waynestate/pinstripe/eslint/typescript' export default [...pinstripe]
For plain JavaScript, import @waynestate/pinstripe/eslint/javascript instead.
Point Prettier at the preset in package.json:
{
"prettier": "@waynestate/pinstripe/prettier"
}
Add scripts that run on every OS:
{
"scripts": {
"lint": "eslint .",
"format": "prettier --write ."
}
}
npm run lint fails on style and accessibility. npm run format fixes the layout. The preset is the off-the-rack
suit: it fits every app, and you only add your own cufflinks. If an app adopts Oxlint for speed, ESLint stays in the toolchain for .vue files, because Pinstripe's Vue and accessibility rules (eslint-plugin-vue and eslint-plugin-vuejs-accessibility) run only in ESLint.
Show ESLint findings in the merge request
Add a second script that uses the gitlab formatter:
{
"scripts": {
"lint:gitlab": "eslint . --format gitlab"
}
}
Point GitLab at the artifact:
artifacts: reports: codequality: gl-codequality.json
--format gitlab comes from eslint-formatter-gitlab, a dependency of Pinstripe, so it resolves from the app's own
node_modules without anything extra to install. In GitLab CI the formatter reads the job's predefined variables:
CI_JOB_NAME picks the job, CI_PROJECT_DIR keeps the report's paths relative to the project root, and the report
path itself is read from that job's artifacts: reports: codequality: line in the CI config. When the config lives
somewhere else (CI_CONFIG_PATH is empty or a URL), set ESLINT_CODE_QUALITY_REPORT to name the file instead.
Pinstripe tests the formatter end to end: the bad Vue fixture becomes a non-empty Code Quality report. The chalk
marks move into the merge request.
Use it for Rector
Rector modernises code for the PHP version your app already supports. Add it as a dev dependency:
composer require --dev rector/rector
Create rector.php and use the Pinstripe preset, adding only your own paths:
<?php $base = require __DIR__ . '/vendor/waynestate/pinstripe/rector/base.php'; return $base->withPaths([__DIR__ . '/src']);
The preset reads the PHP floor from your app's own composer.json, so it cannot drift out of step with the version
the app actually runs on. Check with vendor/bin/rector process --dry-run and apply with vendor/bin/rector process.
Proven on fixtures: composer ci runs the preset against a modern app (tests/fixtures/rector-clean) and an
old-style one (tests/fixtures/rector-old) on every run, and fails if the clean one changes or the old one is left
alone. Rector is the tailor's press: it smooths old code to the standard without changing the cut.
The strict cut
New apps can include vendor/waynestate/pinstripe/phpstan/strict.neon instead of raw.neon or laravel.neon. It keeps the base cut and turns on phpstan/phpstan-strict-rules, so loose comparisons like == fail the build. Existing apps stay on the base cut and baseline their way over.
PHPStan checks phpdoc method signatures from level 3 up, and phpstan/phpstan-strict-rules forces the two switches behind them on for every run it is installed for. The base cut pins reportMaybesInMethodSignatures and reportStaticMethodSignatures off so it stays exactly as documented; the strict cut keeps them on, so it also reports maybe-mismatched overrides and static method overrides.
phpstan/phpstan-strict-rules forces eight more switches on for every run as well. Each one is decided below: low noise and real bugs stay on in every cut, noisy ones are pinned off in the base cut and turned on only in strict.neon.
| Parameter | Raw and Laravel cuts | Strict cut | Why |
|---|---|---|---|
checkExplicitMixedMissingReturn |
on | on | A function declared to return mixed that can fall off the end is a real bug, and rare in practice. |
checkFunctionNameCase |
on | on | A wrongly cased function name works on Windows and macOS and breaks on Linux. |
checkInternalClassCaseSensitivity |
on | on | The same case bug for PHP's own classes. |
checkStrictPrintfPlaceholderTypes |
on | on | %d fed a string is a real formatting bug, and rare. |
checkDynamicProperties |
off | on | Floods legacy code with undeclared properties. |
reportMaybesInPropertyPhpDocTypes |
off | on | Reports "maybe" phpdoc mismatches that legacy docblocks trip constantly. |
reportWrongPhpDocTypeInVarTag |
off | on | Old @var tags are often loose; fixing them is cleanup, not bug hunting. |
reportNonIntStringArrayKey |
off | on | Bleeding-edge check that is noisy on loosely keyed arrays. |
Strict is the tailor's chalk line drawn before the first stitch.
Adopting on an existing app
Generate a baseline with phpstan analyse --generate-baseline, include it in your phpstan.neon, and only ever
shrink it. New code gets no baseline. A baseline is a tailor's chalk line: it marks the fabric as it is, and every
release you take it in, never let it out.
Ignore comments must say why
Every cut sets reportIgnoresWithoutComments and reportUnmatchedIgnoredErrors, so the preset reports an
@phpstan-ignore that has no comment in parentheses and one whose identifier no longer matches an error on that
line. The correct form keeps the reason and the exit plan on the same line:
public function name(?string $v): string { // @phpstan-ignore return.type (why this is safe, and the plan to remove it) return $v; }
Pinstripe tests all three cases: the bare ignore fails, the unmatched ignore fails, and the commented one passes. An ignore without a reason is a pin left in the cushion: nobody remembers why it was there, and it pokes the next tailor.
Baselines only shrink
Pinstripe ships pinstripe-baseline, one PHP command that runs on every OS, to keep that promise. It reads both
the NEON baseline and the PHP format (phpstan-baseline.php).
vendor/bin/pinstripe-baseline count phpstan-baseline.neon
prints how many errors the baseline ignores: the sum of each entry's count, 1 when count is absent.
vendor/bin/pinstripe-baseline check --max=42 phpstan-baseline.neon
exits 1 with baseline grew: N errors, the most allowed is M when the count is above the limit, 0 otherwise. To
compare with the target branch instead of a fixed number, pass a git ref:
vendor/bin/pinstripe-baseline check --against=origin/main phpstan-baseline.neon
In GitLab CI the merge request's base SHA is already in the environment:
vendor/bin/pinstripe-baseline check --against=$CI_MERGE_REQUEST_DIFF_BASE_SHA phpstan-baseline.neon
A baseline is a tailor's chalk line, and this command is the tape measure: it fails the build the moment the line gets longer. The policy behind this section is recorded in docs/adr/0001-phpstan-level-and-baseline-policy.md.
Tested with
Maintainers: how to build, test and release this project, and where its data comes from, is in MAINTAINING.md.
- Pint 1.32.1
- PHPStan 2.3.3
- PHPStan extension-installer 1.4.3
- PHPStan strict-rules 2.1.0
- Larastan 3.13.2
- Rector 2.8.2
- ESLint 10.13.0
- Prettier 3.9.10
- eslint-plugin-vue 10.12.0
- eslint-plugin-vuejs-accessibility 2.6.3
- typescript-eslint 8.72.1
- Node 22, 24 and 26 (the JS presets run in CI on all three; the floor stays 22.13)
Tool floors
Can your app run Pinstripe's tools? Each row shows the floor the locked package itself declares, read verbatim from composer.lock and package-lock.json today and checked against them by tests/ToolFloorsTest.php, so a dependency bump forces a README update in the same change.
PHP tools:
| Tool | Locked version | Needs |
|---|---|---|
laravel/pint |
v1.32.1 | ^8.3.0 |
phpstan/phpstan |
2.3.3 | ^7.4|^8.0 |
phpstan/phpstan-strict-rules |
2.1.0 | ^7.4 || ^8.0 |
phpstan/phpstan-deprecation-rules |
2.0.5 | ^7.4 || ^8.0 |
larastan/larastan |
v3.13.2 | ^8.2 |
rector/rector |
2.8.2 | ^7.4|^8.0 |
Node tools:
| Tool | Locked version | Needs |
|---|---|---|
eslint |
10.13.0 | ^20.19.0 || ^22.13.0 || >=24 |
eslint-plugin-vue |
10.12.0 | ^18.18.0 || ^20.9.0 || >=21.1.0 |
typescript-eslint |
8.72.1 | ^18.18.0 || ^20.9.0 || >=21.1.0 |
prettier |
3.9.10 | >=14 |
- Pinstripe itself needs PHP 8.3 or newer and Node 22.13 or newer, so the Pint floor is the binding PHP floor.
- Pint only reads and rewrites files, so an app still on an older PHP can run Pint with a PHP 8.3 or newer binary (for example a container) while the app itself keeps its runtime.
- ESLint 10 is what sets the Node floor: Node 20 before 20.19 is out.
Measure twice, cut once: these floors are cut from the lock files, and the test keeps the hem honest.
Coming next
Reading the PHP floor from CREED. (Preflight's pint, phpstan and eslint jobs already run your app's Pinstripe config.)
The cast
scripts/readme-art; no suit was harmed, and every hem was pinned first.

