Claude Code hooks: WordPress validation, PHP linting and recovery
Claude Code hooks can check a file path before an edit, run PHP syntax validation afterwards and notify you when a permission decision needs attention. For WordPress development, that gives you three small controls without turning every edit into a full test run. This walkthrough supplies the actual project configuration and a runnable Node.js handler, then explains the failures it catches and the ones it cannot.
Verification status, 7 November 2026: The handler passed 15 direct fixture assertions on Linux with Node.js 22.16.0 and PHP 8.4.24. Claude Code was not installed in the test environment, so we did not observe a genuine Claude-dispatched hook event. This draft must not be labelled an end-to-end Claude Code test until the final dispatch checks below are completed.
The three checks, in execution order
| Event | Trigger | Action | Failure behaviour |
|---|---|---|---|
| PreToolUse | Before an Edit or Write tool call | Validate the requested path and deny edits to protected WordPress files and directories | The tool call is denied before a write if Claude Code dispatches the hook |
| PostToolUse | After a successful Edit or Write call | Run php -l on edited PHP files in plugins or themes | Report the parse error to Claude; the file already contains the bad edit |
| Notification | A permission_prompt notification | Write an audit entry and send an OSC 9 terminal notification | Never blocks the operation; the visible alert depends on the terminal |
Use this on a disposable checkout, not a live WordPress directory. The fixture contains wp-content/plugins/diyai-hooks-demo/demo.php so that PHP checks run against a realistic plugin path without booting WordPress or accessing a database. It needs Node.js, the PHP CLI and Git. The configuration uses onFailure: "block", which requires Claude Code v2.1.295 or later.
Put the rules in project settings, not an Agent prompt
Create .claude/settings.json at the repository root. Both tool hooks match Edit|Write; the notification handler is restricted to permission prompts. Using a Node executable with an argument array avoids shell quoting of file paths, which is especially helpful when WordPress lives in a Windows path containing spaces.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/wp-hooks.mjs", "pre"],
"onFailure": "block",
"timeout": 10
}]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/wp-hooks.mjs", "post"],
"timeout": 15
}]
}
],
"Notification": [
{
"matcher": "permission_prompt",
"hooks": [{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/wp-hooks.mjs", "notify"],
"timeout": 5
}]
}
]
}
}
The PreToolUse handler deliberately stays silent for permitted paths. That leaves Claude Code’s ordinary permission decision intact rather than granting blanket approval. The onFailure setting prevents a missing or crashing pre-check from silently allowing a write on supported CLI versions.
Use one handler for protected paths, PHP lint and the alert
Save the following as .claude/hooks/wp-hooks.mjs. It reads the event JSON from standard input. Its path check resolves existing symlink targets before deciding whether a Write or Edit destination is allowed; it also handles the Windows path separator through Node’s native path functions.
import fs from 'node:fs';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
const root = fs.realpathSync(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const mode = process.argv[2];
function emit(object) {
process.stdout.write(JSON.stringify(object) + '\n');
}
function audit(event, result, file = '') {
fs.appendFileSync(path.join(root, '.claude', 'hook-audit.jsonl'),
JSON.stringify({ timestamp: new Date().toISOString(),
source: event.session_id === 'fixture-test' ? 'fixture' : 'unverified',
event: event.hook_event_name, tool: event.tool_name || null,
result, file }) + '\n');
}
// Canonicalise the deepest existing ancestor to catch symlink aliases.
function realTarget(filePath) {
let current = path.resolve(root, filePath);
const pending = [];
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) throw Error('No existing path ancestor');
pending.unshift(path.basename(current));
current = parent;
}
return path.resolve(fs.realpathSync(current), ...pending);
}
function checkedPath(event) {
const filePath = event.tool_input?.file_path;
if (typeof filePath !== 'string' || !filePath.trim())
throw Error('Missing tool_input.file_path');
const target = realTarget(filePath);
const rel = path.relative(root, target);
if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel))
return { relative: rel, target, denied: 'Edits outside the project are not allowed' };
const segments = rel.split(path.sep).map(s => s.toLowerCase());
const base = segments.at(-1);
const protectedFile = base === 'wp-config.php' || base === '.env' || base.startsWith('.env.');
const protectedDir = segments[0] === '.claude' ||
(segments[0] === 'wp-content' && ['uploads', 'cache', 'upgrade'].includes(segments[1]));
return { relative: rel, target,
denied: protectedFile || protectedDir ? 'Protected WordPress path: ' + rel : null };
}
async function main() {
let raw = '';
for await (const chunk of process.stdin) raw += chunk;
const event = JSON.parse(raw);
const expected = { pre: 'PreToolUse', post: 'PostToolUse', notify: 'Notification' }[mode];
if (!expected || event.hook_event_name !== expected)
throw Error('Incorrect hook event or invocation mode');
if (mode === 'notify') {
audit(event, 'permission-notification');
// OSC 9 is supported by Windows Terminal and several other terminals.
emit({ terminalSequence: '\u001b]9;Claude Code needs permission\u0007' });
return;
}
if (!['Write', 'Edit'].includes(event.tool_name))
throw Error('Unexpected tool name');
const { relative, target, denied } = checkedPath(event);
if (mode === 'pre') {
audit(event, denied ? 'denied' : 'passed', relative);
if (denied) emit({ hookSpecificOutput: {
hookEventName: 'PreToolUse', permissionDecision: 'deny',
permissionDecisionReason: denied
}});
// No allow decision: normal Claude Code permissions still apply.
return;
}
if (denied) {
audit(event, 'post-protected-path', relative);
emit({ decision: 'block', reason: denied + '. The tool has already executed.' });
return;
}
const parts = relative.split(path.sep).map(s => s.toLowerCase());
const inWordPressCode = parts[0] === 'wp-content' &&
['plugins', 'themes'].includes(parts[1]);
if (!inWordPressCode || path.extname(target).toLowerCase() !== '.php') return;
const lint = spawnSync(process.env.PHP_BIN || 'php', ['-l', target],
{ encoding: 'utf8', timeout: 10000 });
const output = String(lint.stderr || lint.stdout || lint.error?.message || '').trim().slice(0, 900);
if (lint.status === 0 && !lint.error) {
audit(event, 'php-lint-passed', relative);
emit({ hookSpecificOutput: { hookEventName: 'PostToolUse',
additionalContext: 'PHP syntax check passed: ' + relative }});
return;
}
audit(event, 'php-lint-failed', relative);
emit({ decision: 'block',
reason: 'PHP lint failed for ' + relative + ': ' + output,
hookSpecificOutput: { hookEventName: 'PostToolUse',
additionalContext: 'The edit has already happened. Repair the PHP syntax, rerun php -l, and inspect git diff before continuing.' }});
}
main().catch(error => {
process.stderr.write('WordPress hook error: ' + error.message + '\n');
process.exitCode = 1; // PreToolUse onFailure=block handles this.
});
Two implementation choices are deliberate. First, the pre-hook protects wp-config.php, environment files, .claude and runtime directories such as uploads and cache. Those are poor targets for an autonomous source-code edit. Secondly, the post-hook invokes php -l with an argument array rather than interpolating the supplied file path into a shell command. Spaces and shell metacharacters in names are therefore treated as path characters, not commands.
What a broken edit looks like, and how to recover
The fixture test deliberately replaces the plugin PHP with the invalid first line below, then invokes the post-edit handler using an event-shaped JSON payload. The local PHP CLI reports an unexpected opening brace. The hook returns a PostToolUse block decision with the lint error and a reminder that the file has already changed.
<?php function broken( {
That response is feedback, not rollback. A second read of the file in the fixture confirms the bad PHP remains on disk. Recovery means correcting the function signature, running the post-hook again, and checking the working tree. For a real repository, retain the change until you understand it rather than resetting a whole plugin directory to clear one syntax error.
php -l wp-content/plugins/diyai-hooks-demo/demo.php
git diff --check
git diff -- wp-content/plugins/diyai-hooks-demo/demo.php
Syntax passing is only the first gate. The PHP parser cannot prove a WordPress hook was registered correctly, that the plugin loads under the site’s PHP version, or that capability checks, nonces and database calls are safe. A plugin integration test or targeted WordPress test still belongs in the verification sequence.
Recorded results: script execution is not Claude dispatch
The accompanying fixture includes tests/run.mjs. It feeds known JSON into the script, edits the local PHP fixture for failure testing and restores the original file. These are the recorded outcomes, not examples invented to demonstrate what Claude might do.
| Local assertion group | Observed result | What the result establishes |
|---|---|---|
| Normal plugin path | Passed | Pre-check returns successfully without overriding standard permissions |
| Protected and outside paths | Passed | Structured deny responses for wp-config.php, environment files, uploads, hook settings and paths outside the checkout |
| Malformed input and symlink | Passed | Unexpected JSON fails and an alias to a protected file is denied |
| Valid, broken and repaired PHP | Passed | Lint detects both syntax failure and a subsequent successful recovery; a failed post-check does not undo the file change |
| Missing PHP binary and notification | Passed | Missing tooling returns a diagnostic; the notification handler writes its audit record and emits a terminal sequence |
Fixture total: 15 passing assertions. Recorded environment: Linux 6.18.57 x64, Node.js v22.16.0 and PHP 8.4.24 CLI. I ran the script directly from Node, without Claude Code installed. The audit identifies these entries as source: fixture rather than presenting them as actual CLI events.
How to prove Claude Code actually dispatched each hook
A project can pass every script test and still be misconfigured in the real client. That is the final publication gate. Open a fresh, authenticated Claude Code CLI from the repository root and record its actual version alongside the permission mode and operating system. Do not use a production checkout.
claude --version
claude --debug-file .claude/claude-debug.log
- Run
/hooksinside Claude Code. Confirm bothEdit|Writehandlers and thepermission_prompthandler appear. A JSON file existing on disk does not prove registration. - Ask Claude to change only the fixture plugin with its
Writetool, not Bash. Confirm the audit contains bothPreToolUseandPostToolUsefor that same edit, and check the CLI debug log for handler execution. - Ask Claude to try writing
wp-config.phpin the disposable fixture. Confirm a denied tool call and that the target was not created or modified. - Ask for a deliberately invalid PHP write. Confirm the
PostToolUsefailure, inspect the still-broken file, then correct the syntax with another Edit or Write and observe a passing lint result. - Leave an ordinary permission prompt unanswered long enough for
permission_promptto be emitted. Confirm its audit and debug entries. Whether a desktop toast appears is separate from whether the hook ran.
Keep the CLI version, config revision, event name, tool name, debug excerpt and before-and-after file state together. An audit entry produced by manually piping JSON is insufficient evidence of lifecycle dispatch. Anonymise paths and session details before sharing any debug log.
See more of our reporting in Google Top Stories, AI Overviews and AI Mode.
Why the guard must not be sold as a WordPress security sandbox
These matchers observe Edit and Write only. A shell command such as sed, a PHP script, an external editor or an MCP tool may change the same file without invoking these hooks. The official hook reference distinguishes tool-scoped post-events from file changes on disk; if you need broader observation, add a separate file-change watcher and still enforce permissions outside the model.
There is another subtle failure path: older CLI versions or a missing registration may not honour a fail-closed setting, while a post-edit decision cannot reverse a completed write. Treat hooks as fast feedback inside a controlled Git workflow, not as replacements for restricted OS permissions, CI, backups or human review. A path guard is also not a complete defence against filesystem races.
Terminals affect notification results too. The sample sends an OSC 9 sequence through Claude Code’s supported terminalSequence output, which Windows Terminal can understand. Non-interactive sessions and incompatible terminals may produce no visible notice, although the audit file can still show that the notification handler executed.
Where hooks fit beside plans, skills and normal code review
Start larger WordPress changes with a scoped design and acceptance checks; the Claude Code plan mode workflow shows how to control the approval boundary. Keep reusable procedures in Claude Code skills, but place narrow, deterministic checks in hooks. Our Claude Code best practices guide covers review gates that still apply after a lint check passes.
Hooks are not a reason to choose a coding Agent by themselves. If the broader decision is between terminals and approval models, compare the workflows in Claude Code versus OpenAI Codex. For long fix-and-retest sessions, the Claude Code pricing guide explains the usage constraints that can make repeated Agent retries expensive in practice.
The current implementation follows the event and output conventions in Anthropic’s Claude Code hooks guide. The next step is not another hook example. It is recording a real Claude Code session that exercises all three registered events and proves the before-and-after state. Until that exists, keep the editorial claim at fixture-tested rather than end-to-end tested.


