Skip to content

Git Hooks and Husky: Automatically Enforcing Code Quality

Published on Oct 28, 2025 | approx. 2 min read |

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:

  1. Husky for hook management
  2. lint-staged for selective linting
  3. PHP CS Fixer with PSR-12 + Symfony ruleset
  4. commitlint for Conventional Commits
  5. Pre-push PHPUnit for test safety
Thomas Wunner

Thomas Wunner

Certified IT specialist for application development with an instructor qualification and over 14 years of experience building scalable web applications with Symfony and Shopware. When not coding, Thomas volunteers as a lifeguard with the Wasserwacht, performs as a DJ, and explores the countryside on his motorbike.

Comments

Comments are provided by Remark42. By loading comments, data is transmitted to our comment server.