Git Hooks — Enforcing Quality at Commit Time

Git Hooks — Enforcing Quality at Commit Time

Reading Time: 3 minutes

Series: Every Laravel Project Should Have These Building Blocks 
Part: 31 of 35 Level: Beginner–Intermediate Prerequisites: Composer ScriptsLarastan, Rector, Pint


What You’ll Learn

  • What the pre-commit hook does and why it matters
  • The exact shell script used across these projects
  • How to process only staged files (fast, under 2 seconds)
  • Auto-installing the hook via composer post-autoload-dump
  • Bypassing the hook when necessary

The Problem with “I’ll Fix It Later”

Code review catches things. CI catches things. But both happen after the commit is made. The feedback loop is: write code → commit → push → wait for CI → fix → re-push. Each cycle is minutes to hours.

The pre-commit hook closes that loop to seconds. Before a commit is recorded, it runs Rector and Pint on only the files you changed. If Rector updates code patterns and Pint fixes formatting, those changes are added to your commit automatically. You never push code that fails the style check.


The Pre-Commit Hook Script

#!/usr/bin/env bash
# .hooks/pre-commit

set -e

# Get the list of staged PHP files only
PHP_FILES=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true)

# Nothing to do if no PHP files are staged
if [ -z "$PHP_FILES" ]; then
    exit 0
fi

echo "→ Running Rector..."
echo "$PHP_FILES" | xargs ./vendor/bin/rector process --clear-cache 2>/dev/null || true

echo "→ Running Pint..."
echo "$PHP_FILES" | xargs ./vendor/bin/pint 2>/dev/null || true

# Re-add any files that Rector/Pint modified
echo "$PHP_FILES" | xargs git add

echo "✓ Done"

Breaking it down:

Line by line:

git diff --cached --name-only --diff-filter=ACMR | grep '\.php$' || true
  • --cached — only staged files (not working directory)
  • --name-only — just filenames, no diff content
  • --diff-filter=ACMR — Added, Copied, Modified, Renamed only (skips Deleted)
  • grep '\.php$' — PHP files only
  • || true — if no PHP files, grep returns exit code 1; || true prevents the set -e from aborting
echo "$PHP_FILES" | xargs ./vendor/bin/rector process --clear-cache 2>/dev/null || true
  • xargs passes the file list to Rector
  • --clear-cache — ensures Rector doesn’t skip changed files due to cache
  • 2>/dev/null — suppress Rector’s verbose output
  • || true — if Rector exits non-zero (it often does when it makes changes), don’t abort
echo "$PHP_FILES" | xargs git add

Re-stages any files that Rector or Pint modified. This means the changes they make are included in your commit automatically.


Installing the Hook

The hook lives at .hooks/pre-commit. It needs:

  1. .hooks/ directory in the repository root
  2. The script file at .hooks/pre-commit
  3. Execute permissions
  4. Git configured to use .hooks/ as the hooks path

Do this in composer.json (see Article 25):

"post-autoload-dump": [
    "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
    "@php artisan package:discover --ansi",
    "@php -r \"file_exists('.hooks/pre-commit') && shell_exec('git config core.hooksPath .hooks');\"",
    "@php -r \"file_exists('.hooks/pre-commit') && shell_exec('chmod +x .hooks/pre-commit');\""
]

After composer install:

git config core.hooksPath
# → .hooks

Every developer on the team gets the hook automatically.


Why --diff-filter=ACMR Matters

Without the filter:

git diff --cached --name-only | grep '\.php$'

This includes deleted files. Trying to run Rector on a deleted file produces an error. --diff-filter=ACMR excludes D (deleted) files.


Performance

The hook processes only staged files. For a typical commit (1–5 files), Rector + Pint complete in under 2 seconds. For a large commit (50 files), expect 5–10 seconds. This is acceptable — it’s much less time than the CI feedback loop.

Compared to running both tools on the entire codebase:

TargetTime
Entire app/ (300 files)~45 seconds
5 staged files~2 seconds
50 staged files~8 seconds

Bypassing the Hook

Sometimes you need to commit work-in-progress that intentionally doesn’t meet the style standards (e.g., a large refactor that’s not finished yet):

git commit --no-verify -m "wip: refactoring auth flow"

--no-verify skips all hooks. Use it sparingly — it’s the escape hatch, not the default.


What the Hook Does NOT Do

The pre-commit hook runs Rector and Pint only. It does not:

  • Run tests (too slow for a commit-time check)
  • Run Larastan (too slow for a commit-time check)
  • Check for debug code (dd()dump()) — that’s a code review responsibility

Tests and Larastan belong in CI (see Article 19). The pre-commit hook is for instant, automatic formatting only.


Key Takeaways

  • The pre-commit hook runs Rector + Pint on staged PHP files before every commit.
  • Processing only staged files keeps it under 2 seconds for typical commits.
  • Re-staging modified files means formatting changes are included in your commit automatically.
  • --diff-filter=ACMR excludes deleted files to prevent errors.
  • Auto-install via post-autoload-dump means developers never manually set up the hook.
  • git commit --no-verify skips the hook — use it as an escape hatch, not a habit.

Tips and Gotchas

⚠️ Warning: The pre-commit hook modifies staged files and re-stages them. If Rector makes a significant change (e.g., renames a method), review the diff before confirming the commit. The hook is automatic, but you’re still responsible for what goes in the commit.

💡 Tip: Keep the .hooks/ directory committed in the repository. This is the whole point — every developer who clones the repo gets the same hooks installed automatically by composer install.

🔥 Expert Note: set -e at the top of the hook script means any command that exits non-zero aborts the hook. The || true on Rector and Pint is deliberate — they return non-zero when they make changes, but that’s the desired behavior (make changes, re-stage, proceed). Without || true, the hook would abort every time Rector does its job.

Further Reading


← Composer Scripts | Next: Testing Strategy →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.