Git Hooks — Enforcing Quality at Commit Time
Series: Every Laravel Project Should Have These Building Blocks
Part: 31 of 35 Level: Beginner–Intermediate Prerequisites: Composer Scripts, Larastan, 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,grepreturns exit code 1;|| trueprevents theset -efrom aborting
echo "$PHP_FILES" | xargs ./vendor/bin/rector process --clear-cache 2>/dev/null || true
xargspasses the file list to Rector--clear-cache— ensures Rector doesn’t skip changed files due to cache2>/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:
- A
.hooks/directory in the repository root - The script file at
.hooks/pre-commit - Execute permissions
- 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:
| Target | Time |
|---|---|
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=ACMRexcludes deleted files to prevent errors.- Auto-install via
post-autoload-dumpmeans developers never manually set up the hook. git commit --no-verifyskips 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 bycomposer install.
🔥 Expert Note:
set -eat the top of the hook script means any command that exits non-zero aborts the hook. The|| trueon 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
- Git Docs: githooks
- Git Docs:
core.hooksPath - Husky — a JavaScript-ecosystem hook manager (different approach, same goal)