DEV Community

Cover image for Laravel Git Pre-Commit Hook: Auto-Format PHP Before Commit
Shakil Alam
Shakil Alam

Posted on Originally published at blog.shakiltech.com

Laravel Git Pre-Commit Hook: Auto-Format PHP Before Commit

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"
Enter fullscreen mode Exit fullscreen mode

Breaking it down:

Line by line:

git diff --cached --name-only --diff-filter=ACMR -- '*.php' ':(exclude)*.blade.php'
Enter fullscreen mode Exit fullscreen mode
  • --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[@]}"
Enter fullscreen mode Exit fullscreen mode
  • "${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[@]}"
Enter fullscreen mode Exit fullscreen mode

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:

  1. A .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 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');\""
]
Enter fullscreen mode Exit fullscreen mode

After composer install:

git config core.hooksPath
# → .hooks
Enter fullscreen mode Exit fullscreen mode

Every developer on the team gets the hook automatically.


Why --diff-filter=ACMR Matters

Without the filter:

git diff --cached --name-only | grep '\.php$'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

--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=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 is gone from Rector and Pint on purpose. Both exit 0 after fixing files, so a non-zero exit is a real failure, and set -e should stop the commit. An earlier version of this hook hid those failures and let unformatted code through.

Further Reading


← Composer Scripts | Next: Testing Strategy →

Top comments (0)