Skip to content

Instantly share code, notes, and snippets.

@edutrul
Created March 31, 2026 17:58
Show Gist options
  • Select an option

  • Save edutrul/0f65cba3e6d3b7b93caa1670e710e8b5 to your computer and use it in GitHub Desktop.

Select an option

Save edutrul/0f65cba3e6d3b7b93caa1670e710e8b5 to your computer and use it in GitHub Desktop.
Claude Code + DDEV: Auto-run PHPCS after every file edit (PostToolUse hook for Drupal projects)

Claude Code + DDEV: Auto-run PHPCS on every file edit

A Claude Code PostToolUse hook that automatically runs PHPCS after Claude edits a PHP/Drupal file inside a DDEV project — feeding violations back to Claude so it can self-correct without any human intervention.

What it does

  • Fires after every Edit or Write tool use
  • Checks only PHP-family files: .php, .module, .install, .inc, .theme
  • Scopes to your custom code only (configurable via the path regex in the script)
  • Runs ddev phpcs <file> with a relative path (required — DDEV maps the project root to /var/www/html, so absolute host paths don't exist in the container)
  • Exit 0 → silent (file is clean or out of scope)
  • Exit 1 → Claude Code feeds the PHPCS output back to Claude, which reports and fixes the violations automatically

Gotchas discovered during development

Problem Cause Fix
Hook not matching .module files Script only checked *.php Added all Drupal PHP extensions to the case statement
"file does not exist" inside DDEV Passed absolute host path to ddev phpcs Derive relative path using git rev-parse --show-toplevel
Script not found Used relative path in settings.json command Read stdin into $input, extract file path, derive root, call script via $root/.claude/scripts/...
Hook output not reaching Claude Used ` tee` (exit code became 0)
Output flashing then disappearing Wrote to /dev/tty, but Claude Code TUI redraws screen Let stdout flow naturally so Claude Code captures it

How Claude Code surfaces the output

When the hook exits non-zero (PHPCS found violations):

  • Claude Code shows PostToolUse:Edit hook error in the UI
  • The PHPCS output is fed to Claude as context
  • Claude reports the violations and can fix them in the next step

When the hook exits zero (file is clean):

  • Completely silent — no banner, no noise

Trade-offs

This hook is a convenience safety net, not a replacement for CI.

Costs:

  • ~1-2s DDEV overhead on every file edit
  • Extra tokens when violations are found (PHPCS output added to context)

If you have CI enforcing PHPCS (e.g. GitHub Actions), this hook is redundant but adds instant feedback during development. Whether it's worth the overhead depends on your workflow.

Setup

  1. Copy post-edit-phpcs.sh to .claude/scripts/post-edit-phpcs.sh in your project
  2. Make it executable: chmod +x .claude/scripts/post-edit-phpcs.sh
  3. Add the hook snippet from settings.json to your .claude/settings.json
  4. Adjust the path regex in step 4 of the script to match your project structure

Requirements

#!/usr/bin/env bash
#
# Claude Code — PostToolUse:Edit hook
#
# Runs PHPCS automatically after Claude edits a file, so coding standard
# violations are caught immediately and reported back to Claude.
#
# How it works:
# Claude Code passes a JSON payload via stdin when a tool finishes.
# We extract the edited file path from that payload, check whether it
# belongs to a scope we care about, and run PHPCS inside DDEV.
#
# Exit codes:
# 0 — file was not in scope, or PHPCS passed (no output, silent)
# 1 — PHPCS found violations (Claude Code surfaces the output to Claude)
#
# ---------------------------------------------------------------------------
# 1. Read the file path from the JSON payload Claude Code sends via stdin.
# The payload looks like: { "tool_input": { "file_path": "..." }, ... }
# ---------------------------------------------------------------------------
file_path=$(jq -r '.tool_input.file_path // empty')
# Nothing to do if no path was found.
[[ -z "$file_path" ]] && exit 0
# ---------------------------------------------------------------------------
# 2. Only run PHPCS on PHP-family extensions used in Drupal projects.
# ---------------------------------------------------------------------------
case "$file_path" in
*.php|*.module|*.install|*.inc|*.theme) ;;
*) exit 0 ;;
esac
# ---------------------------------------------------------------------------
# 3. Resolve the git project root so we can build a relative path.
# DDEV maps the project root to /var/www/html inside the container, so
# passing an absolute host path to `ddev phpcs` would fail with
# "file does not exist". A relative path works for both host and container.
# ---------------------------------------------------------------------------
project_root=$(git -C "$(dirname "$file_path")" rev-parse --show-toplevel 2>/dev/null)
[[ -z "$project_root" ]] && exit 0
relative_path="${file_path#$project_root/}"
# ---------------------------------------------------------------------------
# 4. Only lint files inside the PHPCS-configured scope (see phpcs.xml):
# - Custom modules
# - Custom themes
# - sites/default/settings.php
# ---------------------------------------------------------------------------
if ! echo "$relative_path" | grep -qE '^docroot/(modules|themes)/custom|^docroot/sites/default/settings\.php'; then
exit 0
fi
# ---------------------------------------------------------------------------
# 5. Run PHPCS inside DDEV from the project root.
# Exit code propagates: 0 = clean, 1 = violations found.
# ---------------------------------------------------------------------------
cd "$project_root" && ddev phpcs "$relative_path"
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "input=$(cat); file=$(echo \"$input\" | jq -r '.tool_input.file_path // empty'); root=$(git -C \"$(dirname \"$file\")\" rev-parse --show-toplevel 2>/dev/null); [ -n \"$root\" ] && echo \"$input\" | bash \"$root/.claude/scripts/post-edit-phpcs.sh\"",
"statusMessage": "Running PHPCS..."
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment