AI Code Quality: Make Your Agent Run PHPStan First
Give AI agents a deterministic gate: PHPStan with stubs, Biome rules, Claude Code hooks and git hooks that stop 'done' until the checks pass.

The short answer
To improve AI code quality, give the agent checks that do not depend on its judgment. PHPStan with stubs catches invented functions, Biome rules enforce house conventions, and Claude Code hooks block the word "done" until the checks pass. The agent then sees its own errors instead of you finding them on the dev server.
I have had an agent report a change as done, then hit a fatal error on the dev server for a function that does not exist. The code read fine. Nothing had run it. In one of my packages I later found a filter call using a tag that exists nowhere in Elementor Pro, which does nothing and warns about nothing.
Better prompts do not fix this, because the model has no way to look up what your codebase contains. Tools that read the code can. This article covers three layers of AI code quality checks and how to make an agent run them before it says "done". Static analysis finds invented symbols. Lint rules hold house conventions. Hooks block completion until everything passes. For the wider picture, see QA is part of the build. Everything below comes from PHP and TypeScript projects I maintain.
How does static analysis improve AI code quality?
An agent writes $this->get_setting( 'title' ) inside an Elementor widget. Elementor has no such method. The model produces names that look right, and nothing in its loop knows the real list.
PHPStan does know it. It reads your code plus stub files that describe the plugins you depend on, and at level: max it flags a call to a method that does not exist. I maintain PHPStan stubs for Elementor and Elementor Pro, which Packagist shows installed over 10,000 times. You can install them and run the same check:
composer require --dev phpstan/phpstan arts/elementor-stubs
The stubs extend WooCommerce and WP-CLI (the WordPress command-line tool) classes, so all four stub files must load. With the WP-CLI file missing, PHPStan stops at startup with Class "WP_CLI_Command" not found.
parameters:
level: max
paths:
- src
bootstrapFiles:
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
- vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php
- vendor/php-stubs/wp-cli-stubs/wp-cli-stubs.php
- vendor/arts/elementor-stubs/elementor-stubs.php
I ran this against a widget that makes the invented call:
final class Demo_Widget extends Widget_Base {
protected function render(): void {
$title = $this->get_setting( 'title' );
echo esc_html( (string) $title );
}
}
PHPStan 2.3.1 reports Call to an undefined method Demo_Widget::get_setting() with the identifier method.notFound. Without the Elementor stubs the same file produces three errors, including class.notFound for Widget_Base, which PHPStan does not let you ignore. The real mistake then sits between errors that only mean a stub is missing. To verify your own setup, run vendor/bin/phpstan analyse and confirm the invented method appears. Then remove the Elementor stub line and watch the noise return.
PHPStan also talks to agents. When it detects one through environment variables such as AI_AGENT and CLAUDECODE, it prints an "Instructions for interpreting errors" block. Inside Claude Code that block includes the line "Do not add @phpstan-ignore comments, @phpstan-ignore-next-line comments, or baseline entries to suppress the error." It disappeared when I unset both variables.
The tool trusts its stubs, which has a cost. In one package the Elementor stubs declare Plugin::$instance as non-nullable, so a !== null check reads as always true. The right guard there is class_exists('\Elementor\Plugin') alone. For TypeScript the same job belongs to the compiler: tsc --noEmit.
How do you enforce house rules on AI-written code without bloating CLAUDE.md?
Rules written as prose in CLAUDE.md load into every session, whether or not the agent touches that code. They grow with every rule and depend on the agent remembering them. For a component framework I maintain, I moved the conventions into a Biome plugin. The first commit says it replaces the authoring skill as the source of truth. CLAUDE.md now mentions the plugin and how to suppress a rule, and nothing else about those conventions. One of the AI best practices I rely on is simple: if a machine can check a rule, a machine checks it.
Biome supports custom lint rules written in GritQL. You save a .grit file and list it under plugins in biome.json. The package has 15 rules, mostly banning a raw browser API in favor of a managed wrapper that cleans up when a component is destroyed. This one covers timers:
`$callee($args)` where {
$callee <: or { `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval` },
$program <: contains JsExtendsClause(super_class=$base) where {
$base <: r".*Component$"
},
register_diagnostic(
span=$callee,
message="Use this.ctx.setTimeout(fn, ms) (or this.ctx.setInterval / clearTimeout / clearInterval). Auto-cancelled when the component destroys.",
severity="error"
)
}
The message matters most for agents. When the linter fails, that text is the hint the agent reads, at the moment it looks at the offending line. A message that names the replacement gets fixed on the next attempt. A bare "not allowed" invites a workaround. The rule fires only in files with a class that extends *Component, so it stays quiet in build scripts and third-party helpers.
Some rules catch code that looks correct. One bans instanceof HTMLElement and its relatives. Elements from the Elementor editor's preview iframe belong to another frame, so the check is silently false there and the feature does nothing. A rule can be suppressed with // biome-ignore lint/plugin: <reason>, and the reason has to explain why the rule does not apply. An agent that wants around a rule has to write that down where you will see it in the diff.
To verify the rules, each one has an invalid and a valid fixture. The test runs Biome over both and asserts an exact diagnostic count, zero on the valid file, and that the message contains the hint. The first release shipped ten rules and only six matched, because four patterns missed class members with modifiers like public override. Biome's output looked the same as it does for a clean file. The suite now runs 33 tests that would catch that.
GritQL has limits. It cannot follow data between statements, so matchMedia(q).addEventListener(...) is caught and const mq = matchMedia(q); mq.addEventListener(...) is not. It cannot see filenames, so "a class in Foo.ts must be named Foo" stays in CLAUDE.md.
Why do AI code checks pass when they should fail?
A check that cannot fail gives the agent nothing to react to. Mine failed that way more than once.

In one theme, reportUnmatchedIgnoredErrors was off in the PHPStan config, and three blanket ignores inherited from an older project had stopped matching anything. With the flag off there was no way to tell. I turned it on, deleted the ignores, and found that PHPStan had only been passing from a warm result cache. A fresh run reported four real type errors, where functions such as wp_parse_id_list() and implode() received values of the wrong type. Run phpstan clear-result-cache and check again before you trust a green result.
In a second repository, a Rector skip list and a lint allowlist pointed at files that had been renamed or never existed. An allowlist entry that matches no file never fires, and a skip that matches no path stops protecting what it was written for. I added a script that checks every path in rector.php and the test configs exists on disk, and wired it into composer check. Biome has its own version: plugin globs match against the absolute file path, so a root-relative src/** silently never fires and needs a **/ prefix.
Static analysis also has a blind spot: strings. A filter name is a string, so PHPStan cannot know that nothing registers the tag. One of my packages called elementor_pro/query_control/get_query_args/current_query, which exists nowhere in Elementor Pro. WordPress treats apply_filters on an unregistered tag as a no-op, so nothing failed, and the code sat behind a misleading comment. The fix was a test that pins the one real tag and expects the invented name never to be called. Tests are the next layer, and they belong in your definition of done. For code an agent writes in volume, mutation testing with Infection checks that those tests fail when the logic is wrong.
The same limit applies to accessibility. The accessibility review checklist for AI-generated components treats an automated scan as the floor of a review. A passing check is where the review of AI production code starts.
How do Claude Code hooks make an AI agent run checks before it says done?
An agent skips a check it is not forced to run. Hooks remove that choice. You can attach checks at five points, and the earlier points give faster feedback.
| When | Where | What runs |
|---|---|---|
| Right after an edit | Claude Code PostToolUse hook | Lint the one file |
| When the agent says it is finished | Claude Code Stop hook | PHPStan and tsc |
| On commit | git pre-commit | Biome, Rector, PHP_CodeSniffer, tsc |
| On push | git pre-push | PHPStan and tests |
| On pull request | continuous integration (CI) | Everything, from a cold cache |

The Claude Code hooks documentation describes the two events that matter here. PostToolUse fires after a tool call succeeds, and exit code 2 shows your stderr to Claude. The edit has already happened, so the hook cannot undo it, but the agent sees the error immediately. Stop fires when Claude finishes responding, and exit code 2 prevents it from stopping and continues the conversation with your stderr as the reason. Hooks live in a settings file: ~/.claude/settings.json applies everywhere, and .claude/settings.json is committed with the project. A third file, .claude/settings.local.json, stays personal. Each hook receives a JSON payload on stdin.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/lint-edited.sh" }]
}
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/verify-stop.sh" }] }
]
}
}
#!/usr/bin/env bash
# lint-edited.sh: check the file that was just touched
file=$(jq -r '.tool_input.file_path // empty')
case "$file" in
*.ts) pnpm exec biome lint "$file" >&2 || exit 2 ;;
*.php) php -l "$file" >&2 || exit 2 ;;
esac
#!/usr/bin/env bash
# verify-stop.sh: block "done" while the project's checks fail
input=$(cat)
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# Nothing edited this turn: skip the checks.
[ -z "$(git status --porcelain)" ] && exit 0
# Already continued once because of this hook: let the agent stop and report.
[ "$(jq -r '.stop_hook_active' <<<"$input")" = "true" ] && exit 0
out=$(composer phpstan 2>&1 && pnpm check:types 2>&1) || {
printf '%s\n' "$out" | tail -40 >&2
exit 2
}
I ran both scripts. The lint script exits 2 with Biome's message on a debugger; statement and 0 on a clean file. The stop script exits 2 with the PHPStan error, skips a clean working tree, and lets the agent stop when stop_hook_active is true. Stop fires on every turn, including ones where the agent only answered a question, so the git status line skips the checks when nothing changed. The documentation describes stop_hook_active as true when Claude is already continuing because of a stop hook, and says to check it so errors the agent cannot fix do not trap it. Claude Code also caps stop-hook continuations at eight in a row, and CLAUDE_CODE_STOP_HOOK_BLOCK_CAP raises the cap. Most events time out after 600 seconds by default, but keep PostToolUse fast and put the whole-project checks on Stop.
Git hooks catch the agent when it commits and cover tools with no hook system. I use Lefthook in the JavaScript-first repositories and CaptainHook in the PHP package. Pre-commit rewrites and re-stages files, and pre-push runs the slower checks. Order matters. I had Rector running after phpcbf. Rector prints changed nodes with space indentation and can import a class name from a docblock, so misformatted files reached the commit. Running Rector first and letting phpcbf clean up fixed it, with phpcbf exit code 1 treated as success because it means the files were fixed. These hooks stay advisory (LEFTHOOK=0 skips them for a work-in-progress commit), so CI remains the authoritative gate.
When should you refactor AI-written code?
Refactor suggesters answer a different question from the checks above. PHPStan asks whether the code is wrong. These tools ask what should change next, and an agent that applies their output blindly can break working code.
On the TypeScript side I run Fallow and Knip. Fallow reports dead code and circular imports, and it measures duplication and complexity. It cannot see components that are discovered from rendered HTML, so I list the entry points in .fallowrc.jsonc. Even then the output needs checking. After I added one unrelated file, Fallow 3.32.0 changed the unused-class-member count for one library's classes from 9 to 13. Before I delete anything it reports, I check the callers. Knip has a similar blind spot for packages consumed only from Sass, which no JavaScript import graph can see, so its config lists those dependencies as ignored.
On the PHP side I run Rector with four prepared sets: dead code, code quality, type declarations and early returns. Style and naming sets stay off because PHP_CodeSniffer owns style. The config is pinned to PHP 8.0, so it cannot suggest readonly or enums in a package that ships to shared hosting. It also skips the files that are tied to Elementor's runtime, with a comment on each explaining that their behavior can only be exercised in a live Elementor. Pre-commit runs Rector on staged PHP files, so every change it makes is visible in the commit.
A practical trigger for a larger refactor: when a report for the same module keeps growing between agent sessions, plan a deliberate rework instead of another patch.
How do you set this up, step by step?
The steps below follow the order that pays off fastest. Each one ends with a way to check that it works.
Step 1: Run PHPStan at max with stubs. Install phpstan/phpstan and the stubs for every plugin you call, then set level: max. To verify, call a method that does not exist and confirm PHPStan reports method.notFound.
Step 2: Move house rules into Biome rules. Write one .grit file per rule, with a message that names the replacement. To verify, keep an invalid fixture that must fire an exact number of times and a valid fixture that must stay silent.
Step 3: Lint each edited file. Add a PostToolUse hook with the matcher Edit|Write that runs the linter on the file path from the payload. To verify, have the agent write a debugger; line and confirm it receives the error in the same turn.
Step 4: Block "done" while checks fail. Add a Stop hook that runs PHPStan and the TypeScript compiler, and check stop_hook_active so unfixable errors cannot trap the agent. To verify, rename a function in a call and confirm the agent is sent back to fix it.
Step 5: Add git hooks as the backstop. Run formatters and fixers on pre-commit and the slow checks on pre-push. To verify, commit a file that breaks a rule and confirm the hook stops it.
Step 6: Prove every check can fail. Turn on reportUnmatchedIgnoredErrors, run PHPStan from a cold cache, and check that every path in your skip lists exists. To verify, delete a file named in a skip list and confirm your config check fails.
Key takeaways
- PHPStan with stubs for every plugin you call catches invented functions and methods. Prompting does not.
- Rules a pattern can match belong in Biome plugin rules whose messages name the replacement.
CLAUDE.mdkeeps the rest. - A
Stophook runs the checks before the agent can say done, and aPostToolUsehook gives quick per-file feedback. - A check that cannot fail is worse than none. Test the rules and clear the cache, and check every path in your configs.
Frequently asked questions
Can an AI coding agent run PHPStan on its own?
Yes, if you tell it to in `CLAUDE.md` or in the task prompt. That depends on the agent remembering, though, and it can report done without running anything. A Claude Code `Stop` hook runs PHPStan every time the agent tries to finish and sends the errors back, so the check happens whether the agent remembers or not.
Do Claude Code hooks only work with specific tools?
No. A hook is any command that reads a JSON payload on stdin and returns an exit code, so it can run any of your checks, such as PHPStan or `tsc`, or a shell script of your own. The `matcher` field limits a `PostToolUse` hook to certain tools, such as `Edit|Write`, so it runs only after files change.
Will a Claude Code Stop hook loop forever?
No. The hook receives `stop_hook_active`, which is true when Claude is already continuing because of a stop hook, and the documentation says to check it before blocking again. Claude Code also ends the turn after eight consecutive stop-hook continuations, and `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` changes that limit.
Should house rules go in CLAUDE.md or lint rules?
Put any rule a pattern can match into a lint rule. The diagnostic reaches the agent at the offending line and costs no context in sessions that never touch that code. Keep in `CLAUDE.md` what a rule cannot express, such as naming a class after its file, which GritQL cannot check.
Why does PHPStan pass locally but fail on a fresh run?
PHPStan caches results, so a warm cache can hide errors that a cold run reports. In one of my themes, a fresh run found four real type errors that had been passing. Run `phpstan clear-result-cache` before you trust a green result, and run CI from a cold cache.
Senior WordPress Engineer
I develop premium motion-focused themes and plugins for WordPress focusing primarily on Elementor ecosystem. I can either design (in Figma) and build using modern WordPress stack and tools. I'm also fine with supporting legacy/outdated WordPress installations from jQuery era. Currently I have 10 plugins published on WP.org https://profiles.wordpress.org/artemsemkin/ that I maintain.

