Dev.to AI 🤖 Ai 👁 0 📖 5 min read

Claude Code Hooks for Laravel: Don't Let the Agent Say "Done" Until the Checks Pass

A rule in CLAUDE.md is a request. A hook is a gate. Here is a reproduction from my Laravel test app. One route gets a new name, the way an agent renames things while "cleaning up": Route::get('/contact', [ContactCont

A rule in CLAUDE.md is a request. A hook is a gate.

Here is a reproduction from my Laravel test app. One route gets a new name, the way an agent renames things while "cleaning up":

Route::get('/contact', [ContactController::class, 'create'])->name('contact.create'); // was contact.form

The controller still redirects to contact.form, and four lines in a feature test still request it. php -l is happy, the route file looks fine, and nothing complains until someone submits the contact form or runs that one feature test. If the agent doesn't, it reports the task as done.

That is how architecture drifts in a codebase with an AI agent in it: not in one big rewrite, but one renamed route, one skipped check and one "done" at a time. You can write "always run the tests" into CLAUDE.md. Most of the time the agent will. The times it doesn't are the ones that reach production.

What a Stop hook is

Claude Code runs hooks at fixed points of a session, configured in .claude/settings.json. The Stop hook runs when the agent has finished responding and is about to hand the turn back to you. Three details from the docs matter for a gate:

  • The hook blocks only with exit code 2 (stderr goes back to Claude) or with JSON on stdout: {"decision": "block", "reason": "..."}. Claude then keeps working with your reason as its next instruction.
  • Any other non-zero exit code is a non-blocking error. Claude stops anyway.
  • The input has stop_hook_active: true when Claude is already continuing because of a stop hook, and Claude Code ends the turn itself after eight consecutive continuations. That is how you avoid an endless loop on a check the agent cannot fix.

The one-liner that doesn't block

The first hook most people write:

{
  "hooks": {
    "Stop": [
      { "hooks": [{ "type": "command", "command": "php artisan test" }] }
    ]
  }
}

When a test fails, php artisan test exits with 1. By the rules above, that is a non-blocking error, and the agent finishes anyway. The gate is open.

Make it exit 2 and it blocks:

php artisan test || { echo "Tests fail. Fix them before finishing." >&2; exit 2; }

It still has four problems:

  1. It runs on every stop, including when the agent just answered a question.
  2. It runs the whole suite, so it gets slow, and slow gates get removed.
  3. It sees only tests. Larastan errors, code style, a config file that no longer caches, a provider that no longer boots and a renamed route all pass, as long as no test happens to touch them.
  4. It can be satisfied by deleting the failing test.

A hook that checks what the change needs

This is what I built into laravel-ai-verify, the Laravel port of the agent tooling from Semitexa, the PHP framework I'm working on. One command installs it:

composer require --dev semitexa/laravel-ai-verify
php artisan ai:verify:install --hook

It merges a single entry into .claude/settings.json, next to whatever hooks you already have:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "php \"${CLAUDE_PROJECT_DIR}/artisan\" ai:verify:hook",
            "timeout": 600
          }
        ]
      }
    ]
  }
}

When Claude is about to finish, the hook runs php artisan ai:verify on the uncommitted work. That command looks at which files changed and plans only the checks they need: the tests that reach the changed code, Larastan on the changed lines, your project's own style tool (Pint, PHP-CS-Fixer, PHP_CodeSniffer or ECS, whichever has a config), a fresh route:list, config:cache or event:list for the kind of file you touched, and migrations in pretend mode. A changed test file must keep its tests and assertions, so deleting the failing test fails too.

For the renamed route, three checks ran in under a second, and the hook answered with a block. This is the reason Claude received, trimmed:

✗ artisan:routes: 5 reference(s) to undefined route names
  › app/Http/Controllers/ContactController.php:27 Route [contact.form] is not defined
    (The route was renamed or removed. Update this reference, or restore the name with ->name('contact.form').)
  › tests/Feature/ContactFormTest.php:14 Route [contact.form] is not defined
  › tests/Feature/ContactFormTest.php:30 Route [contact.form] is not defined
  ...
Re-run php artisan ai:verify after fixing. Do not delete or weaken tests to make it pass.

File, line and the fix, which is what an agent needs to act on it without asking you.

After a block it checks again every time the agent changes something, so a half-done fix (the controller updated, the tests still calling the old name) is sent back too. Two things keep it from being annoying. It skips a working tree it has already judged, so answering a question in a dirty repo, or stopping again without changing anything, does not re-run the checks. And it gives up after five rounds in one continuation, below Claude Code's own cap of eight.

What it doesn't do

  • It is a Claude Code hook. Cursor, Codex and Copilot get the same instructions through AGENTS.md, a git hook stamps commits that passed, and CI can run the same checks with the package's GitHub Action, but only Claude Code has a Stop event that can hold the turn.
  • It verifies checks, not intent. A commit can pass every check and still change the wrong line; the package flags some of those from the commit message, as a warning.
  • A gate is only as good as the checks behind it. If no check could read the changed files (a part of the app with no tests and no static analysis), the verdict is incomplete, not pass. The hook blocks only on fail, so an incomplete run does not hold the agent back; php artisan ai:verify shows it.

Which checks do you make your agent pass before it may say "done"?

Source and docs: https://github.com/semitexa/laravel-ai-verify. The ideas come from Semitexa: https://semitexa.com/?utm_source=devto&utm_medium=post&utm_campaign=claude-code-hooks-laravel

📰 Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.