6 min read

Stop Asking Claude to Run Pint: Enforce Your PHP Toolchain With Hooks

Claude Code hooks turn Pint, PHPStan, and Pest from polite CLAUDE.md requests into shell commands that always run. Here is the exact setup for PHP.

Featured image for "Stop Asking Claude to Run Pint: Enforce Your PHP Toolchain With Hooks"

Every PHP developer using Claude Code has written some version of this line in their CLAUDE.md:

After editing any PHP file, run `vendor/bin/pint` and `vendor/bin/phpstan analyse`.

It works. Most of the time. Then you hit a long session, the context gets compacted, and three files later Claude is writing code that would never survive your CI. The instruction was never a rule. It was a suggestion competing with everything else in the context window.

Hooks fix that, and they are the single highest-leverage thing a PHP developer can configure in Claude Code. A hook is a shell command that Claude Code runs at a fixed point in its lifecycle. It is not a prompt, so it cannot be forgotten, reprioritized, or compacted away.

The shape of a hook

Configuration nests three levels deep, and getting this straight up front saves a lot of squinting at JSON:

  1. A hook event, the lifecycle point, like PostToolUse or Stop
  2. A matcher group, the filter, like “only for the Edit and Write tools”
  3. One or more hook handlers, the thing that actually runs

Put it in .claude/settings.json at the project root and commit it. That is the whole point: your team gets the same guardrails, and so does every Claude Code session anyone opens in the repo.

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/php-quality.sh",
            "timeout": 120,
            "statusMessage": "Pint + PHPStan"
          }
        ]
      }
    ]
  }
}

${CLAUDE_PROJECT_DIR} resolves to the project root where the session started, so the hook works no matter which subdirectory Claude happens to be in.

The script that does the work

The handler receives the tool call as JSON on stdin. For Edit and Write, tool_input.file_path is the file that just changed.

#!/usr/bin/env bash
# .claude/hooks/php-quality.sh
set -uo pipefail

input=$(cat)
file=$(jq -r '.tool_input.file_path // empty' <<< "$input")

# Only PHP files that still exist.
[[ "$file" == *.php && -f "$file" ]] || exit 0

# Format first, so the analyser reads the final bytes.
vendor/bin/pint "$file" > /dev/null 2>&1

# Then analyse just this file, not the whole app.
if ! report=$(vendor/bin/phpstan analyse --no-progress --error-format=raw "$file" 2>&1); then
    {
        echo "PHPStan rejected ${file}:"
        echo "$report"
    } >&2
    exit 2
fi

exit 0

Two details in there matter more than they look.

Analyse one file, not the project. vendor/bin/phpstan analyse app/ on a real Laravel application takes long enough that you will disable the hook by the end of the week. Pointing it at the single changed file usually finishes in under a second with a warm result cache.

Exit 2, not exit 1. This is the mistake nearly everyone makes on their first hook. Claude Code treats exit code 1 as a non-blocking error: it logs a notice and carries on. Exit code 2 is the blocking exit code, and it is the only one that means anything through the code alone. On PostToolUse the tool has already run, so exit 2 cannot undo the edit, but it does something better. It shows your stderr to Claude, which turns the PHPStan report into feedback Claude reads and acts on. In practice you watch it fix its own type errors before you have read the diff.

Stderr from a hook that exits 0 goes nowhere Claude can see it. If you want the model to know something went wrong, exit 2.

Telling Claude the file moved under it

Pint rewrites the file after Claude wrote it, which means Claude’s mental model of that file is now slightly wrong. The additionalContext field solves this. Instead of exiting silently, print JSON:

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse",
    "additionalContext": "Pint reformatted this file after the edit, so the contents on disk differ from what was written."
  }
}

Claude Code wraps that string in a system reminder and drops it next to the tool result. Note the phrasing. The documentation is explicit that additionalContext should read as factual statements rather than imperative commands, because text framed as out-of-band system instructions can trip Claude’s prompt-injection defenses and get surfaced to you instead of used as context. “The file changed” works. “You must re-read the file” is asking for trouble.

The test-suite gate

PostToolUse catches individual files. The Stop event fires when Claude is about to hand the turn back to you, and it is where the test suite belongs.

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/gate-tests.sh",
            "timeout": 300,
            "statusMessage": "Running the suite"
          }
        ]
      }
    ]
  }
}
#!/usr/bin/env bash
# .claude/hooks/gate-tests.sh
set -uo pipefail

guard="$(git rev-parse --show-toplevel)/.git/claude-test-gate"

# One retry per turn. Without this you can loop.
if [[ -f "$guard" ]]; then
    rm -f "$guard"
    exit 0
fi

if ! out=$(vendor/bin/pest --compact 2>&1); then
    touch "$guard"
    { echo "The suite is red. Fix it before finishing:"; tail -n 40 <<< "$out"; } >&2
    exit 2
fi

exit 0

On Stop, exit 2 prevents Claude from stopping and continues the conversation with your stderr as the reason. That is genuinely powerful and genuinely dangerous. A Stop hook that always exits 2 will never let the turn end, which is why the guard file is there: it lets the gate fire once, gives Claude one shot at a fix, and then stands down.

Things worth knowing before you build a wall of these

Handlers run in parallel. All matching hooks for an event fire at once. If two of them write to the same file, that is your problem to solve.

The if field is a cheap filter. Handlers accept an if field using permission-rule syntax, like "Edit(**/*.php)", which skips the process spawn entirely when it does not match. The catch is that it holds exactly one rule, with no && or ||, and the rule names a specific tool. Covering both Edit and Write means two handlers. For a script that already checks the extension in one line, filtering in Bash is simpler.

Default timeouts are generous. Command hooks default to 600 seconds. That is long enough for a stuck PHPStan process to make the session feel broken, so set timeout explicitly on anything that touches the filesystem at scale.

A broken hook fails quietly. If the script path is wrong, the shell exits 127 and you get a non-blocking notice, then everything proceeds as if the gate were not there. Watch the first run after you wire one up. A typo in settings.json is a silently disabled policy.

Hooks are not a security boundary. Timeouts and the best-effort if matcher mean a hook can quietly not fire. Use the permission system for hard allow and deny rules; use hooks for quality gates.

Where this leaves you

Three files, maybe forty lines total, and the difference in output quality is not subtle. Claude stops shipping code that fails phpstan --level=max, because it sees the failure the instant it happens rather than when CI catches it twenty minutes later. Your CLAUDE.md gets shorter, because the mechanical rules moved somewhere mechanical and what remains is genuine project context.

Start with the PostToolUse formatter. It is five minutes of work, it cannot break anything, and it will change how the tool feels immediately. Add PHPStan once you trust it. Save the Stop gate for last, and test it on a branch you do not mind interrupting.

Sources: Hooks reference, Claude Code documentation, Automate actions with hooks, Laravel Pint documentation, PHPStan command line usage, Claude Code for Symfony and PHP: The Setup That Actually Works, Javier Eguiluz.