DEV Community

Cover image for CaptainHook: Git Hooks That Keep Bad PHP Out of main
Gabriel Anhaia
Gabriel Anhaia

Posted on

CaptainHook: Git Hooks That Keep Bad PHP Out of main


You push a one-line fix. You switch tasks. Eight minutes later CI goes red: PHP CS Fixer wants two spaces where you left four, and PHPStan found a call on a nullable. Now you context-switch back, fix it, push again, and wait another eight minutes. The bug never left your laptop, but it cost you two round-trips through a queue.

That feedback loop is backwards. The cheapest place to catch a style violation or a type error is the moment before the commit exists. That is what git hooks are for, and CaptainHook is the PHP-native way to run them.

What CaptainHook is

CaptainHook is a git hook manager written in PHP. You describe your hooks in a captainhook.json file that lives in the repo, commit it, and every teammate runs the same gates. No .git/hooks scripts copied around by hand, no shell snippets in a wiki that drift out of date.

It hooks into the standard git events: pre-commit, commit-msg, pre-push, and the rest. Each event runs a list of actions. An action can be a CLI command (your linter, PHPStan, PHPUnit) or a built-in PHP class that ships with CaptainHook.

Install it as a dev dependency:

composer require --dev captainhook/captainhook
Enter fullscreen mode Exit fullscreen mode

Then wire the hooks into your local .git:

vendor/bin/captainhook install
Enter fullscreen mode Exit fullscreen mode

That reads captainhook.json and writes the git hook stubs. Run it once per clone.

Make the team get the hooks for free

The install step is the part people forget. A hook that only you have installed protects nobody else. CaptainHook ships a Composer plugin that fixes this: it runs the install step automatically whenever anyone runs composer install.

Add it to composer.json:

{
  "require-dev": {
    "captainhook/captainhook": "^5.25",
    "captainhook/hook-installer": "^1.0"
  },
  "config": {
    "allow-plugins": {
      "captainhook/hook-installer": true
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Now a new hire clones the repo, runs composer install, and the hooks are live. The gate travels with the code.

The pre-commit gate: lint, analyse, and only touch staged files

Here is where most setups go wrong: they run the linter and static analyser across the whole codebase on every commit. On a large project that is thirty seconds you pay for a one-file change, so people start committing with --no-verify and the gate dies.

CaptainHook solves this with a placeholder that expands to the staged files only. {$STAGED_FILES|of-type:php} resolves to the PHP files in the current commit, and nothing else.

{
  "pre-commit": {
    "enabled": true,
    "actions": [
      {
        "action": "vendor/bin/php-cs-fixer fix {$STAGED_FILES|of-type:php} --dry-run --diff"
      },
      {
        "action": "vendor/bin/phpstan analyse {$STAGED_FILES|of-type:php}"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The fixer runs in --dry-run mode so it reports violations and blocks the commit instead of rewriting files behind your back. PHPStan analyses only what you changed. A one-file commit checks one file. The gate stays under a second, so nobody reaches for --no-verify.

You can skip an action entirely when nothing relevant is staged. Conditions guard an action so it never runs on an empty match:

{
  "action": "vendor/bin/phpstan analyse {$STAGED_FILES|of-type:php}",
  "conditions": [
    {
      "exec": "\\CaptainHook\\App\\Hook\\Condition\\FileStaged\\OfType",
      "args": ["php"]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Commit a change to a README and the analyser action is skipped. Commit PHP and it fires.

Run the fast tests, not the whole suite

You do not want the full test suite on pre-commit. That belongs on pre-push or CI. But a targeted, fast unit run at commit time catches the obvious breakage before it is even a commit.

{
  "action": "vendor/bin/phpunit --testsuite=unit --stop-on-failure"
}
Enter fullscreen mode Exit fullscreen mode

Keep it to the suite that runs in a couple of seconds. The rule of thumb: pre-commit runs what is fast enough that you would not notice it. Anything slower moves one stage out.

The pre-push gate: the heavier round

pre-push is where the full suite lives. It runs once before code leaves your machine, so a slower check here is fine. This is the last gate before the network, and it is far cheaper than a failed CI job.

{
  "pre-push": {
    "enabled": true,
    "actions": [
      {
        "action": "vendor/bin/phpstan analyse"
      },
      {
        "action": "vendor/bin/phpunit"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Full-codebase PHPStan here, staged-only PHPStan at commit time. You get quick feedback while typing and a complete check before anything reaches the remote.

commit-msg: enforce a message format

A commit-msg hook reads the message you just wrote and validates it. CaptainHook has a built-in action for the classic seven rules of a good commit message (capitalised subject, imperative mood, blank line before the body, and so on):

{
  "commit-msg": {
    "enabled": true,
    "actions": [
      {
        "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Beams"
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Want granular control instead of the whole preset? The Rules action composes individual rule classes:

{
  "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Rules",
  "options": [
    "\\CaptainHook\\App\\Hook\\Message\\Rule\\CapitalizeSubject",
    "\\CaptainHook\\App\\Hook\\Message\\Rule\\LimitSubjectLength"
  ]
}
Enter fullscreen mode Exit fullscreen mode

If your team ties commits to a tracker, the Regex action rejects any message that does not reference a ticket:

{
  "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Regex",
  "options": {
    "regex": "#(PROJ-[0-9]+|Merge)#"
  }
}
Enter fullscreen mode Exit fullscreen mode

For Conventional Commits, Ben Ramsey's ramsey/conventional-commits package plugs straight into CaptainHook with a validate action, so feat:, fix:, and the rest are checked at commit time. That keeps a clean changelog generatable from history.

Why local gates beat waiting on CI

None of this replaces CI. Someone will still commit with --no-verify, and a hostile change can strip the hooks. CI is the gate you cannot go around, and it stays.

What local hooks buy you is speed of feedback. The failure shows up in the terminal you are already looking at, before the commit exists, without a push and a queue wait. CI then becomes the safety net for the cases that slip past, not the first place you learn your code does not lint.

The split that works:

  • pre-commit — fast, staged-files-only: formatter check, PHPStan on changed files, a quick unit suite.
  • pre-push — the full test suite and whole-codebase static analysis.
  • commit-msg — message format, ticket reference, conventional-commit shape.
  • CI — the authoritative re-run of everything, plus the checks a laptop cannot do (matrix builds, integration tests against real services).

Because captainhook.json sits in the repo and hook-installer wires it up on composer install, the whole team runs identical gates. The config is reviewed like any other file. When you tighten a rule, it lands in a PR and everyone picks it up on the next composer install.

Keeping bad code out of main is not one big wall at the end. It is a series of cheap, fast checks that get more thorough the closer you get to the shared branch. CaptainHook is how you put the first, fastest ones where they cost the least.

If this was useful

Hooks are a boundary. They sit at the edge of your workflow and keep noise out of the branch everyone shares, without leaking into the code they guard. That edge-versus-core split is the same instinct hexagonal architecture applies inside the application: push the mechanical concerns to the perimeter, keep the domain clean. Decoupled PHP is about drawing those lines on purpose, so the parts that change fast stay away from the parts that must not.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)