Git Pre-Commit Hook: How to Run `php -l` on Staged Changed/Added Files (Excluding Deleted)

As a PHP developer, there’s nothing more frustrating than pushing code with a syntax error—only to have CI pipelines fail, teammates flag it, or (worse) production break. Catching these errors before they leave your local machine is critical for maintaining code quality and productivity.

Git pre-commit hooks are the perfect solution. These scripts run automatically before a commit is finalized, allowing you to enforce checks (like syntax validation) and block bad commits early. In this guide, we’ll create a pre-commit hook that runs php -l (PHP’s built-in syntax checker) on only staged, changed/added PHP files (excluding deleted files). This ensures you never commit invalid PHP code again.

Table of Contents#

  1. Prerequisites
  2. What is a Git Pre-Commit Hook?
  3. Step 1: Understand the Goal
  4. Step 2: Create the Pre-Commit Hook Script
  5. Step 3: Make the Hook Executable
  6. Step 4: Test the Hook
  7. Troubleshooting Common Issues
  8. Customization Options
  9. Conclusion
  10. References

Prerequisites#

Before getting started, ensure you have:

  • Git installed: The hook relies on Git’s command-line tools.
  • PHP installed: php -l requires the PHP CLI (command-line interface). Verify with php -v in your terminal.
  • Basic Git knowledge: Familiarity with staging files (git add) and committing (git commit).
  • Command-line access: You’ll need to edit files and run shell commands.

What is a Git Pre-Commit Hook?#

Git hooks are scripts stored in the .git/hooks directory of your repository. They trigger automatically on specific Git events (e.g., pre-commit, pre-push).

The pre-commit hook runs after you run git commit but before the commit is created. If the script exits with a non-zero status code (e.g., due to an error), Git aborts the commit. This makes it ideal for enforcing checks like:

  • Syntax validation (e.g., php -l for PHP, eslint for JavaScript).
  • Code style (e.g., phpcs for PHP_CodeSniffer).
  • Secret scanning (e.g., preventing API keys in commits).

By default, Git provides sample hooks in .git/hooks (e.g., pre-commit.sample), but these are inactive. To use a hook, rename the file (remove .sample) and make it executable.

Step 1: Understand the Goal#

Our hook needs to:

  1. Target only staged files: We don’t care about unstaged changes—only what’s marked for commit.
  2. Include changed/added files: Focus on files that are new (A), modified (M), copied (C), or renamed (R).
  3. Exclude deleted files: No need to check files marked for deletion (D), as they won’t be in the commit.
  4. Filter for PHP files: Only run php -l on .php files (skip .txt, .js, etc.).
  5. Block invalid commits: If php -l detects a syntax error, abort the commit and show the error.

Step 2: Create the Pre-Commit Hook Script#

Let’s build the hook step-by-step.

Step 2.1: Navigate to the Hooks Directory#

In your Git repository, open the .git/hooks folder. This directory is hidden by default, so use ls -a to view it:

cd /path/to/your/repo
cd .git/hooks

Step 2.2: Create the pre-commit File#

Create a new file named pre-commit (no extension) and open it in a text editor (e.g., VS Code, nano, or vim):

nano pre-commit

Step 2.3: Add the Hook Script#

Paste the following script into the file. We’ll break down how it works afterward:

#!/bin/bash
 
# Initialize error flag to track syntax issues
ERROR=0
 
# Step 1: Get list of staged files (added/modified/copied/renamed; exclude deleted)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
 
# Step 2: Filter staged files to only .php extensions
PHP_FILES=$(echo "$STAGED_FILES" | grep -E '\.php$')
 
# If no PHP files are staged, exit early (no checks needed)
if [ -z "$PHP_FILES" ]; then
  exit 0
fi
 
echo "🔍 Running PHP syntax check (php -l) on staged PHP files..."
 
# Step 3: Run php -l on each staged PHP file
for FILE in $PHP_FILES; do
  # Skip if file no longer exists (edge case for renames)
  if [ ! -f "$FILE" ]; then
    echo "⚠️  Warning: File $FILE not found (may have been renamed). Skipping."
    continue
  fi
 
  # Run php -l and capture output/exit code
  PHP_LINT_OUTPUT=$(php -l "$FILE" 2>&1)
  PHP_LINT_EXIT_CODE=$?
 
  # If php -l finds a syntax error
  if [ $PHP_LINT_EXIT_CODE -ne 0 ]; then
    echo -e "\n❌ Syntax error in $FILE:"
    echo "$PHP_LINT_OUTPUT"
    ERROR=1  # Set error flag to block commit
  fi
done
 
# Step 4: Block commit if errors were found
if [ $ERROR -ne 0 ]; then
  echo -e "\n🚨 Aborting commit due to syntax errors in PHP files. Fix errors and try again."
  exit 1
else
  echo -e "\n✅ PHP syntax check passed. Commit can proceed!"
  exit 0
fi

Breaking Down the Script#

Let’s explain key parts of the script:

1. git diff --cached --name-only --diff-filter=ACMR#

  • --cached: Targets staged files (what’s in the "index" for commit).
  • --name-only: Outputs only filenames (not full diffs).
  • --diff-filter=ACMR: Includes files with status:
    • A: Added
    • C: Copied
    • M: Modified
    • R: Renamed
      Excludes deleted files (D) and others (e.g., U for unmerged).

2. grep -E '\.php$'#

Filters the staged files to retain only those ending in .php (case-sensitive). This ensures we don’t run php -l on non-PHP files (e.g., .html, .css).

3. php -l "$FILE"#

php -l (short for "lint") checks the PHP file for syntax errors without executing it. It returns:

  • 0 exit code: No syntax errors.
  • Non-zero exit code: Syntax error found (e.g., missing semicolon, unclosed brace).

4. Error Handling#

  • The ERROR flag tracks if any PHP file has syntax issues.
  • All errors are printed to the terminal so you can see all issues at once (no need to fix one, re-run, and find the next).
  • If ERROR=1, the script exits with 1, aborting the commit.

Step 3: Make the Hook Executable#

For Git to run the hook, it needs execute permissions. Run:

chmod +x pre-commit

Step 4: Test the Hook#

Let’s verify the hook works with a test scenario.

Test 1: Commit a PHP File with a Syntax Error#

  1. Create a PHP file with a syntax error (e.g., missing semicolon):

    <?php
    echo "Hello, World"  # Oops! No semicolon here
  2. Stage the file:

    git add test.php
  3. Try to commit:

    git commit -m "Add test file"

Expected Result: The hook runs, detects the syntax error, and aborts the commit:

🔍 Running PHP syntax check (php -l) on staged PHP files...

❌ Syntax error in test.php:
PHP Parse error:  syntax error, unexpected end of file, expecting ',' or ';' in test.php on line 2

🚨 Aborting commit due to syntax errors in PHP files. Fix errors and try again.

Test 2: Fix the Error and Commit#

  1. Add the missing semicolon to test.php:

    <?php
    echo "Hello, World";  # Fixed!
  2. Re-stage and commit:

    git add test.php
    git commit -m "Add test file with valid syntax"

Expected Result: The hook runs, finds no errors, and the commit succeeds:

🔍 Running PHP syntax check (php -l) on staged PHP files...

✅ PHP syntax check passed. Commit can proceed!
[main abc1234] Add test file with valid syntax
 1 file changed, 2 insertions(+)
 create mode 100644 test.php

Troubleshooting Common Issues#

Hook Isn’t Running#

  • Check the filename: Ensure the file is named pre-commit (no .txt or .sample extension).
  • Check permissions: Run ls -l .git/hooks/pre-commit—it should show -rwxr-xr-x (executable). If not, re-run chmod +x pre-commit.

php: command not found#

The hook relies on php being in your system’s PATH. Verify PHP is installed:

php -v  # Should output PHP version

If missing, install PHP (e.g., via Homebrew on macOS: brew install php, or XAMPP for Windows).

Non-PHP Files Are Being Checked#

The grep -E '\.php$' line ensures only .php files are scanned. If non-PHP files are being checked, verify the regex: '\.php$' matches files ending with .php (case-sensitive). For case-insensitive matching (e.g., .PHP), use grep -iE '\.php$'.

Deleted Files Are Being Checked#

The --diff-filter=ACMR flag explicitly excludes deleted files (D). If deleted files are still being scanned, ensure your Git version supports --diff-filter (all modern Git versions do, but update Git if needed: git --version).

Customization Options#

Tweak the hook to fit your workflow:

Exclude Specific Directories#

To skip PHP files in vendor/ or node_modules/, add a grep -v filter to exclude paths:

PHP_FILES=$(echo "$STAGED_FILES" | grep -E '\.php$' | grep -v '^vendor/' | grep -v '^node_modules/')

Show All Errors Before Aborting#

The current script prints errors as it finds them. To collect all errors first and print them at the end (for cleaner output), modify the loop to store errors in a variable:

ERROR_MESSAGES=""
 
for FILE in $PHP_FILES; do
  # ... (previous checks)
  if [ $PHP_LINT_EXIT_CODE -ne 0 ]; then
    ERROR_MESSAGES+="\n❌ Syntax error in $FILE:\n$PHP_LINT_OUTPUT\n"
    ERROR=1
  fi
done
 
if [ $ERROR -ne 0 ]; then
  echo -e "\n🚨 Found syntax errors:$ERROR_MESSAGES"
  exit 1
fi

Run Additional Checks#

Extend the hook to run other tools, like PHP CodeSniffer (phpcs) for style checks:

# After PHP syntax check, run phpcs (if installed)
if [ $ERROR -eq 0 ] && command -v phpcs &> /dev/null; then
  echo "🔍 Running PHP CodeSniffer (phpcs)..."
  phpcs $PHP_FILES || ERROR=1
fi

Conclusion#

A pre-commit hook that runs php -l on staged PHP files is a simple yet powerful way to catch syntax errors early. By blocking invalid commits locally, you save time, reduce CI failures, and keep your codebase clean.

This hook is lightweight, fast, and customizable—adapt it to your team’s needs (e.g., adding style checks or excluding directories). With this in place, you’ll never push a syntax error again!

References#