• 7 min read

Claude Code Hooks Can Now Fail Closed: onFailure Block for PHP Guardrails

Claude Code 2.1.295 adds onFailure "block" to hooks, so a broken guard script stops the action instead of waving it through. Here is a PHP setup.

Featured image for "Claude Code Hooks Can Now Fail Closed: onFailure Block for PHP Guardrails"

In my September post on Claude Code hooks I listed a caveat that bothered me: a broken hook fails quietly. Typo in the script path, and the shell exits 127, you get a non-blocking notice, and everything proceeds as if the gate was never there. A policy that silently turns itself off is not much of a policy.

Claude Code v2.1.295, released October 8, 2026, closes that gap. According to the release notes as tracked by Havoptic, command and HTTP hooks now accept onFailure: "block", so a hook that cannot start, times out, or exits with an unexpected code blocks the action instead of letting it through. This post shows what that means in practice, with a guard script written in PHP.

The problem it solves

The hooks reference is blunt about the default behavior. On most events, when a hook fails or times out, Claude Code still carries out the action. The docs give the exact scenario: a policy hook with a wrong path or a crashing script lets everything through.

Three outcomes exist for a hook, per the docs:

  • Exit 0 is success, and the action goes ahead unless your JSON output says otherwise.
  • Exit 2 is a blocking error. On events that can block, Claude Code stops the action.
  • Any other exit code, or any other failure such as not starting or printing invalid JSON, is a non-blocking error. The action goes ahead.

That third bucket is where a guard script dies without anyone noticing. A PHP fatal error exits 255. A missing php binary exits 127. A timeout renders no decision at all. Before 2.1.295, all of those meant “allow.”

A PHP guard script

Here is a PreToolUse guard that refuses a few destructive commands. It is written in PHP because that is what is already on your machine. I have not run this script in a live Claude Code session, so test it on a throwaway project first.

#!/usr/bin/env php
<?php

declare(strict_types=1);

$payload = json_decode(stream_get_contents(STDIN), true, flags: JSON_THROW_ON_ERROR);
$command = $payload['tool_input']['command'] ?? '';

$rules = [
    '/\bartisan\s+(migrate:fresh|migrate:reset|db:wipe)\b/' => 'Destructive artisan command.',
    '/\bDROP\s+(TABLE|DATABASE)\b/i'                        => 'Destructive SQL statement.',
    '/\bgit\s+push\b.*--force\b/'                           => 'Force push.',
];

foreach ($rules as $pattern => $reason) {
    if (preg_match($pattern, $command) === 1) {
        fwrite(STDERR, "Blocked by guard.php: {$reason}\n");
        exit(2);
    }
}

exit(0);

The hook receives the tool call as JSON on stdin, and for the Bash tool the command string lives at tool_input.command. Exit 2 blocks the call and sends your stderr back to Claude as the reason, so Claude can pick a safer route instead of retrying the same thing.

Notice JSON_THROW_ON_ERROR. If the payload is ever malformed, PHP throws an uncaught exception and exits non-zero. By default, that would let the command through. That is exactly the case onFailure exists for.

Wiring it up with onFailure

Put this in .claude/settings.json and commit it:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "php",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.php"],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

Two choices here are worth explaining.

Exec form with args. When args is present, Claude Code resolves command as an executable and spawns it directly, with no shell involved. Each args element is passed as one argument exactly as written, so a project path with spaces cannot break the call. The docs recommend args whenever a hook references a path placeholder like ${CLAUDE_PROJECT_DIR}. Omit it only when you need pipes or &&.

An explicit short timeout. A guard that parses a string should finish in milliseconds. Ten seconds is generous. Without onFailure, a timeout on PreToolUse does not block the call, and the docs say plainly not to count on a stalled hook acting as a gate. With "onFailure": "block", the message says timed out and the call is stopped.

Testing that it really fails closed

The Claude Code docs suggest a simple test: leave the script missing and ask Claude to run a Bash command such as ls. With onFailure set to "block", the call is blocked and the error includes this text:

PreToolUse:Bash hook error: [node ${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js]: failed; blocking because onFailure is "block"

That sample is from the docs, which use a Node script, so your output will name php and guard.php instead. Rename guard.php to guard.php.bak, ask Claude to run ls, and confirm it is refused. Then rename it back. Do the same test with the setting removed so you can see the difference: the missing script becomes a non-blocking notice and ls runs.

Do this once per project. It takes a minute and proves the whole chain: matcher, path placeholder, php on the PATH, and the new flag.

What counts as a failure

The docs list five conditions that trigger the block when onFailure is "block":

  • The command hook cannot start, for example because the script or executable does not exist.
  • It exits with a code other than 0 or 2. This applies even if it printed JSON that allows the action, so to return a JSON decision, exit 0.
  • An HTTP hook’s connection fails, or the response status is not 2xx.
  • The hook reaches its timeout.
  • The JSON output cannot be parsed or fails schema validation. Plain-text stdout from a command hook is not a failure.

The second bullet is the one that surprises people. If your script prints permissionDecision: "allow" and then exits 1 because of a stray warning, onFailure: "block" treats the whole run as failed. Keep your exit codes clean.

Where it does not apply

The field has no effect on Stop, SubagentStop, TaskCompleted, and TeammateIdle hooks. On those events, exit 2 sends Claude back to keep working, and Claude cannot repair a hook that will not run. That matters if you copied my test-suite gate from the September post: it is a Stop hook, so onFailure does nothing there.

Background command hooks, meaning those with async or asyncRewake, are also excluded.

For events where it does apply, a failure does what exit 2 does on that event. A PreToolUse failure blocks the tool call, and a UserPromptSubmit failure blocks the prompt. The one exception is PermissionRequest, where a failure denies the request.

Should you turn it on everywhere?

No. Fail-closed is the right default for hooks that enforce a policy: destructive command guards, protected-file checks, secret scanners. A false block there costs you a retry.

It is the wrong default for convenience hooks. A PostToolUse formatter that runs Pint should probably stay on the default. If Pint is missing on a colleague’s machine, you do not want every edit to turn into an error. Match the setting to the stakes.

One more reminder from the docs: hooks are still not a hard security boundary. The if filter is best-effort, so for strict allow and deny rules, use the permission system and treat hooks as the layer on top.

Where this leaves you

If you already have a policy hook in a PHP project, the upgrade is one line. Update to 2.1.295 or later, add "onFailure": "block" to the guard handlers, and run the rename test. Your guardrails stop depending on a script path that nobody remembers to check.

Teams that ship their .claude/settings.json through the repo get the most from this. One broken checkout or one CI image missing php no longer means a silent bypass. It means a loud, early error, which is exactly when you want to hear about it.

Sources: Hooks reference, Claude Code documentation, Claude Code v2.1.295 release notes, Havoptic, Claude Code October 2026 releases, Havoptic, Claude Code changelog on GitHub.