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 -euo pipefail # Staged PHP files (Blade excluded: Rector only understands PHP, and big # templates make commits slow) and staged Blade templates, listed separately. mapfile -t PHP_FILES < <(git diff --cached --name-only --diff-filter=ACMR -- '*.php' ':(exclude)*.blade.php') mapfile -t BLADE_FILES < <(git diff --cached --name-only --diff-filter=ACMR -- '*.blade.php') # Nothing to do if no PHP or Blade files are staged if [ ${#PHP_FILES[@]} -eq 0 ] && [ ${#BLADE_FILES[@]} -eq 0 ]; then exit 0 fi # Re-staging below adds whole files. If a file also has unstaged edits, # those would sneak into the commit, so stop and let the developer decide. for file in "${PHP_FILES[@]}" "${BLADE_FILES[@]}"; do if ! git diff --quiet -- "$file"; then echo "pre-commit: $file has unstaged changes. Stage or stash them first." >&2 exit 1 fi done # A non-zero exit means the tool itself failed (both exit 0 after fixing # files), so `set -e` aborts the commit instead of reporting success. if [ ${#PHP_FILES[@]} -gt 0 ]; then echo "→ Running Rector..." php vendor/bin/rector process --clear-cache "${PHP_FILES[@]}" fi # Blade formatting needs prettier, prettier-plugin-blade and # prettier-plugin-tailwindcss from npm; without them, leave Blade alone. PINT_ARGS=() FORMAT_FILES=("${PHP_FILES[@]}") if [ ${#BLADE_FILES[@]} -gt 0 ] && [ -d node_modules/prettier-plugin-blade ]; then PINT_ARGS=(--blade) FORMAT_FILES+=("${BLADE_FILES[@]}") fi # Never call Pint without paths: it would reformat the whole project if [ ${#FORMAT_FILES[@]} -gt 0 ]; then echo "→ Running Pint..." php vendor/bin/pint "${PINT_ARGS[@]}" "${FORMAT_FILES[@]}" # Re-add the files Rector/Pint modified git add -- "${FORMAT_FILES[@]}" fi echo "✓ Done" Breaking it down:
Line by line:
git diff --cached --name-only --diff-filter=ACMR -- '*.php' ':(exclude)*.blade.php' -
--cached– only staged files (not working directory) -
--name-only– just filenames, no diff content -
--diff-filter=ACMR– Added, Copied, Modified, Renamed only (skips Deleted) -
-- '*.php' ':(exclude)*.blade.php'– PHP files only, with Blade templates left out of Rector (it only understands PHP) -
mapfile -t– reads one file per line into an array, so paths with spaces stay intact
php vendor/bin/rector process --clear-cache "${PHP_FILES[@]}" -
"${PHP_FILES[@]}"passes the staged files to Rector as separate arguments -
--clear-cache– ensures Rector doesn’t skip changed files due to cache - No
2>/dev/null– if Rector breaks, you see why - No
|| true– Rector and Pint exit 0 after fixing files, so a non-zero exit means the tool itself failed (no PHP on the PATH, a crash) and the commit stops instead of going through unformatted
git add -- "${FORMAT_FILES[@]}" Re-stages any files that Rector or Pint modified. This means the changes they make are included in your commit automatically. Because re-adding stages the whole file, the hook first refuses to run when a staged file also has unstaged edits; otherwise those edits would slip into the commit.
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 Laravel Composer Scripts):
"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 Laravel CI/CD with GitHub Actions). The pre-commit hook is for instant, automatic formatting only.
FAQs
How do I run Pint before every commit in Laravel?
A pre-commit hook that runs Pint on staged files and blocks the commit on failure — installed for the whole team via a composer script. Setup below.
Git hooks vs CI -do I need both?
Yes: hooks give instant local feedback; CI is the enforcement layer that can’t be skipped with –no-verify
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|| trueis gone from Rector and Pint on purpose. Both exit 0 after fixing files, so a non-zero exit is a real failure, andset -eshould stop the commit. An earlier version of this hook hid those failures and let unformatted code through.
Further Reading
- Git Docs: githooks
- Git Docs:
core.hooksPath - Husky — a JavaScript-ecosystem hook manager (different approach, same goal)