Run PHPStan and show only the errors on lines you changed relative to a base
branch (default trunk) — instead of being buried under every pre-existing
error in the codebase. Handy when you run PHPStan at a stricter level: locally
than the project's committed baseline.
There is no native "diff mode" in PHPStan itself — its baseline feature is a
snapshot, not a git diff. (The closest off-the-shelf alternative is
reviewdog with an errorformat, but
that's a Go binary.) This is a small self-contained pair of scripts:
| Script | What it is |
|---|---|
bin/phpstan-diff |
Bash wrapper — the thing you normally run. Runs PHPStan with --error-format=json and pipes it through the filter. |
bin/phpstan-diff-filter |
PHP filter — reads a PHPStan JSON report on stdin, keeps only messages on changed lines, prints a PHPStan-style table (or JSON), exits 1 if anything remains. |
git clone <this repo> ~/repos/phpstan-diff
ln -s ~/repos/phpstan-diff/bin/phpstan-diff ~/bin/phpstan-diff # or add bin/ to $PATHRequirements: bash, git, php (CLI), and PHPStan reachable from the project
directory (see Locating PHPStan below).
Run it from the project root, just like phpstan analyse:
phpstan-diff # analyse the whole configured project, show errors on lines changed vs trunk
phpstan-diff --changed # faster: only run PHPStan on the .php files that changed vs trunk
phpstan-diff --changed --exclude=tests # the changed files, minus anything under tests/
phpstan-diff --changed --include=src # only changed files under src/
phpstan-diff -- src/wp-includes/rest-api.php # restrict PHPStan (and the diff) to given paths
phpstan-diff --base=6.7-branch # diff against a different base ref
phpstan-diff --staged # like the default, but exclude unstaged edits (committed + staged vs trunk)
phpstan-diff --staged --base=HEAD # only the staged changes (pre-commit-hook scope)
phpstan-diff --format=json | jq '.files | keys'Options:
--base=<ref>— base ref to diff against. Default:$PHPSTAN_DIFF_BASEortrunk.--staged— diff the staged snapshot (the git index) against the base instead of the working tree: errors on committed + staged changes, with unstaged edits ignored. Add--base=HEADto narrow it to staged changes only (pre-commit-hook scope). (PHPStan still analyses the working tree, not the staged blob.)--changed— when no paths are given, only run PHPStan on the.phpfiles that differ from the base. Much faster for PR review; may miss errors that a change introduces in other files.--include=<glob>— keep only files matching this repo-relative path or glob, intersected with the files that actually changed. Repeatable (a file is kept if it matches any--include). A bare directory matches everything under it (--include=src⇒src/Foo.php); globs work too (--include='src/*',*.php), and*may span/.--exclude=<glob>— drop files matching this repo-relative path or glob (--exclude=tests). Repeatable; applied after--include, and an exclude wins over an include. With--changed, excluded files aren't handed to PHPStan at all, so the run is faster too.--format=table(default) |--format=json—jsonemits the filtered PHPStan JSON document (same shape as--error-format=json, with non-changed files/messages removed).
Note
--include/--exclude vs the positional <paths>. The positional <paths>
are PHPStan's analysis targets — handed to phpstan analyse verbatim to decide
what it scans. --include/--exclude are filters over the changed files: they
understand globs and, crucially, let you say "everything except tests"
(--exclude=tests) — something a list of paths can't express. They're orthogonal
and stack: phpstan-diff --changed --exclude=tests derives the changed .php
files, drops the test ones (so PHPStan never sees them), then line-filters the rest.
What counts as a "changed line": every line that differs between
git merge-base <base> HEAD and your working tree — i.e. your branch commits
plus staged and unstaged edits. With --staged, the comparison is against the
git index rather than the working tree, so unstaged edits drop out (committed +
staged only); adding --base=HEAD on top of that leaves just the staged changes.
Exit status: 1 if any errors remain after filtering, 0 if none, 2 on
internal errors (PHPStan didn't produce JSON, not a git repo, bad ref, …).
bin/phpstan-diff figures out how to run PHPStan, in this order:
$PHPSTAN_DIFF_ANALYSE_CMDif set — used verbatim, our extra args appended. E.g.PHPSTAN_DIFF_ANALYSE_CMD='composer phpstan --'orPHPSTAN_DIFF_ANALYSE_CMD='vendor/bin/phpstan analyse -c phpstan.neon'.composer phpstan --if Composer reports aphpstanscript.vendor/bin/phpstan analyse --memory-limit=2G(honours a custom Composerbin-dir).- A globally-installed
phpstan analyse --memory-limit=2G. - Otherwise it errors and tells you to install PHPStan or set
PHPSTAN_DIFF_ANALYSE_CMD.
This means it picks up whatever config composer phpstan already uses — including a
local stricter phpstan.neon that overrides phpstan.neon.dist.
Drop this in .git/hooks/pre-commit (chmod +x it) to block commits that
introduce errors on the lines being committed. It only checks staged .php
files, never touches your working tree, and is bypassable with
git commit --no-verify (or disabled with PHPSTAN_DIFF_PRECOMMIT_SKIP=1):
#!/usr/bin/env bash
set -euo pipefail
[ -n "${PHPSTAN_DIFF_PRECOMMIT_SKIP:-}" ] && exit 0
if command -v phpstan-diff >/dev/null 2>&1; then
phpstan_diff="$(command -v phpstan-diff)"
elif [ -x "$HOME/repos/phpstan-diff/bin/phpstan-diff" ]; then
phpstan_diff="$HOME/repos/phpstan-diff/bin/phpstan-diff"
else
echo "pre-commit: phpstan-diff not found — skipping PHPStan check." >&2
exit 0
fi
files=()
while IFS= read -r f; do
[ -n "$f" ] && files+=("$f")
done < <(git diff --cached --name-only --diff-filter=ACMR -- '*.php')
[ "${#files[@]}" -eq 0 ] && exit 0
# phpstan-diff analyses files on disk and maps results onto the staged hunks; if
# a staged file also has unstaged edits, that mapping is approximate — warn only.
git diff --quiet -- "${files[@]}" || \
echo "pre-commit: note — some staged files have unstaged changes; line mapping is approximate." >&2
# --base=HEAD scopes the diff to just the staged changes; without it, --staged
# would also report on changes committed earlier in the branch.
exec "$phpstan_diff" --staged --base=HEAD -- "${files[@]}"Warning
Don't git stash in the hook. It's tempting to git stash --keep-index
so PHPStan sees exactly the staged content, but with newly-added or
partially-staged files the matching git stash pop can hit conflicts and
leave your tree in a bad state. The hook above accepts a slightly approximate
line mapping for partially-staged files instead — the common
git add <file> && git commit case (working tree == index) is still exact.
Tell Claude: "to check PHPStan, run phpstan-diff --changed and only act on what
it prints — those are the errors this branch introduced." (You may want to add
that to a project note / Claude memory so it's used automatically.)
- Messages with no line, or a line outside every changed hunk (some file-level errors like "class not found"), are dropped.
- Truly untracked files aren't part of any diff, so PHPStan errors in them are
dropped —
git addthem (or commit) to have them considered. - Path quoting: paths with unusual characters in
git diffheaders may not be matched perfectly. - The table output approximates
phpstan analyse's table; it isn't pixel-perfect and doesn't emit editor hyperlinks (but relativepath:linestrings are still clickable in most IDE terminals).
This tool (both bin/ scripts and this README) was written by Claude Code
using the Claude Opus 4.7 (1M context) model (claude-opus-4-7[1m]), from the
prompt below, then reviewed and verified against a real repository.
Original prompt used to plan this tool
I have a stricter phpstan.neon compared with phpstan.neon.dist. I always use the former with PhpStorm and it is automatically picked up with
composer phpstan. However, the problem is that it reports many many errors for lines that aren't changed in the current branch compared with trunk. I want to create a command line tool called something like phpstan-diff which analyzes the project (or the supplied file paths) and then filters out anything that isn't among the modified lines in the current branch compared withtrunk. And the base branch could also be configurable. Maybe this should be something which simply takes the output of acomposer phpstan -- --error-format=jsonand then filters out the results (e.g. using jq). if there are errors, then the filter script can have an exit code of 1 or else otherwise an exit code of 0. This filter script I can then use in a pre-commit hook as well as something which I can tell Claude about to use whenever I'm reviewing a PR. So it should be something that I can easily invoke from the command line. Maybe that means one filter command script and then another simple wrapper bash script around that filter command script. The wrapper is what I would normally invoke. The wrapper command can should format the errors in a way likephpstan analyzenormally outputs.An example of something I've used for this in the past:
composer phpstan -- --error-format=json -- src/wp-includes/rest-api.php 2>/dev/null \ | jq '.files["/Users/westonruter/repos/wordpress-develop/src/wp-includes/rest-api.php"].messages | map(select(.line >= 2927 and .line <= 3031) | del(.ignorable))'Is there already a way to get PHPStan to only report errors for lines in a diff? Or do I need to create a new helper? If new helpers are needed, let's put them in the empty repo I just created at
~/repos/phpstan-diff/
MIT © Weston Ruter