The goal is clear: no poorly formatted code, no broken tests, and no forgotten var_dump() calls should end up in the repository. Git hooks are the mechanism to enforce this — automatically, without every developer having to remember.
What Are Git Hooks?
Git hooks are executable scripts in the .git/hooks/ directory of a repository. Git runs them automatically at specific events:
| Hook | Timing | Typical Use |
|---|---|---|
pre-commit |
Before the commit | Linting, tests, code formatting |
commit-msg |
After commit message input | Validate Conventional Commits |
pre-push |
Before the push | Run the test suite |
post-merge |
After a merge | composer install, npm install |
pre-rebase |
Before a rebase | Warning on unsaved changes |
Manual Pre-Commit Hook
#!/bin/bash
# .git/hooks/pre-commit
# Apply PHP CS Fixer to staged PHP files
STAGED_PHP=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.php$')
if [ -n "$STAGED_PHP" ]; then
./vendor/bin/php-cs-fixer fix $STAGED_PHP --no-interaction
git add $STAGED_PHP
fi
The problem: .git/hooks/ is not committed to the repository, so every developer has to install the hook manually.
Husky: Version-Controlled Hooks
Husky solves this problem: Git hooks are stored in the repository and automatically activated for all developers.
Installation
# Install Husky
npm install --save-dev husky
# Initialize Husky
npx husky init
This creates .husky/pre-commit and adds "prepare": "husky" to package.json. Now Husky is automatically set up on npm install.
Pre-Commit Hook with Husky
# .husky/pre-commit
#!/bin/sh
npx lint-staged
lint-staged: Only Check Changed Files
lint-staged runs linters and formatters only on the files staged for commit — not on the entire project. This makes hooks significantly faster.
package.json Configuration
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.ts": [
"eslint --fix",
"prettier --write"
],
"*.scss": [
"stylelint --fix"
],
"*.php": [
"./vendor/bin/php-cs-fixer fix --no-interaction"
]
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0"
}
}
Configuring PHP CS Fixer
PHP CS Fixer is the standard tool for PSR-12-compliant PHP formatting in Symfony projects.
.php-cs-fixer.dist.php
<?php
declare(strict_types=1);
$finder = PhpCsFixer\Finder::create()
->in(__DIR__ . '/src')
->in(__DIR__ . '/tests')
->name('*.php')
->exclude('var')
->exclude('vendor');
return (new PhpCsFixer\Config())
->setRules([
'@PSR12' => true,
'@Symfony' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => ['sort_algorithm' => 'alpha'],
'no_unused_imports' => true,
'trailing_comma_in_multiline' => true,
'phpdoc_align' => ['align' => 'vertical'],
'declare_strict_types' => true,
'single_quote' => true,
])
->setFinder($finder)
->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');
.php-cs-fixer.cache in .gitignore
.php-cs-fixer.cache
Commit Message Validation
Conventional Commits is a standard for structured commit messages:
type(scope): description
feat(auth): add OAuth2 login support
fix(cart): prevent negative quantities
docs(api): update endpoint documentation
commit-msg Hook
# .husky/commit-msg
#!/bin/sh
npx --no -- commitlint --edit "$1"
commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat',
'fix',
'docs',
'style',
'refactor',
'test',
'chore',
'revert',
]],
'subject-max-length': [2, 'always', 100],
'subject-case': [0], // Allow mixed German/English
},
};
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Pre-Push Hook with PHPUnit
Tests can be run before a push:
# .husky/pre-push
#!/bin/sh
echo "Running PHPUnit tests..."
vendor/bin/phpunit --testdox --no-coverage
if [ $? -ne 0 ]; then
echo "Tests failed! Push aborted."
exit 1
fi
For DDEV projects:
# .husky/pre-push
#!/bin/sh
echo "Running PHPUnit tests in DDEV..."
ddev exec vendor/bin/phpunit --no-coverage
if [ $? -ne 0 ]; then
echo "Tests failed! Push aborted."
exit 1
fi
Post-Merge Hook: Automatically Update Dependencies
After a merge or checkout, Composer and npm packages can be installed automatically:
# .husky/post-merge
#!/bin/sh
# Check if composer.lock has changed
CHANGED_FILES=$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)
if echo "$CHANGED_FILES" | grep -q "composer.lock"; then
echo "composer.lock changed, running composer install..."
ddev exec composer install
fi
if echo "$CHANGED_FILES" | grep -q "package-lock.json"; then
echo "package-lock.json changed, running npm install..."
ddev exec npm install
fi
Complete Configuration for a Symfony Project
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.php": [
"./vendor/bin/php-cs-fixer fix --no-interaction"
],
"*.{ts,js}": [
"eslint --fix"
],
"*.scss": [
"stylelint --fix"
],
"*.{json,md,yaml,yml}": [
"prettier --write"
]
}
}
.husky/pre-commit:
#!/bin/sh
npx lint-staged
.husky/commit-msg:
#!/bin/sh
npx --no -- commitlint --edit "$1"
Skipping Hooks (Only in Exceptional Cases!)
# Skip a single hook
git commit --no-verify -m "WIP: work in progress"
# Only for emergencies — never in normal workflows!
HUSKY=0 git push
Note: --no-verify should ideally not be possible in a team setup. CI/CD pipelines serve as the last line of defence in case someone bypasses hooks.
Conclusion
Git hooks with Husky and lint-staged are one of the most effective measures for consistent code quality in a team. Once set up, code formatting runs automatically — developers no longer have to think about PHP CS Fixer. Conventional Commits help with automatic changelogs and a meaningful Git history. For Symfony projects, I recommend this combination:
- Husky for hook management
- lint-staged for selective linting
- PHP CS Fixer with PSR-12 + Symfony ruleset
- commitlint for Conventional Commits
- Pre-push PHPUnit for test safety
Comments
Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.