TL;DR: Writing rules for AI agents helps, but AI is non-deterministic — violations will slip through. Enforce project-specific constraints with custom PHPStan/PHPCS rules for mechanical checking. Let an AI agent draft the custom rules for you, and you can implement them quickly.
CLAUDE.md Alone Isn’t Enough
If you’re using an AI agent like Claude Code, you probably write project rules in CLAUDE.md or rule files. Things like:
- “Use ‘Plan’ instead of the old term ‘Course’ everywhere in the code”
- “
DateTime::createFromTimestamp()must always receive a timezone as the second argument” - “Core domain classes must not call infrastructure directly”
The agent respects these rules and writes compliant code — most of the time. But AI is non-deterministic. The same instruction doesn’t always produce the same output. When the context gets long or the agent is in the middle of a complex refactoring, it can “accidentally” generate code that violates the rules, even though they’re written right there in the rule files.
Humans forget, and AI slips up too. So what do you do?
Just enforce them mechanically with static analysis rules.
PHPStan and PHP_CodeSniffer (PHPCS) ship with powerful standard rulesets, but they can’t cover project-specific constraints. That’s why you need to create custom rules for your project.
But writing custom rules is tedious. PHPCS’s Sniff interface, PHPStan’s Rule interface, token manipulation, AST node traversal — just reading the docs on all these unfamiliar APIs is enough to break your spirit. I don’t wanna read the docs. Nope.
Or so I thought —
Hand It to the Agent
This is where AI agents come in. Tell Claude Code “create a rule that does X” and it generates the Sniff or Rule boilerplate, plus tests.
Example prompts:
Create a custom PHPCS Sniff that:
- Flags an error when code contains the word "コース"
- Allow it in comments
- Write tests too
Create a custom PHPStan rule that:
- Classes in App\Domain\*\Core must not depend on anything outside their domain and PHP built-ins
- Allow configuring exceptions via a neon file
The agent understands PHPCS and PHPStan APIs, so you don’t need to know how token handling or AST traversal works. You just describe what to forbid.
What a time to be alive, huh.
Directory Structure and Namespaces
Before creating custom rules, let’s set up the directory structure. PHPCS and PHPStan have different conventions for organizing rules.
PHPCS Custom Sniff Structure
PHPCS Sniffs follow a strict convention: the directory name must match the coding standard name. For a standard named AppSniff, the structure looks like this:
project-root/
├── php-rules/
│ └── AppSniff/
│ ├── ruleset.xml # Ruleset definition
│ └── Sniffs/
│ ├── ForbiddenWord/
│ │ ├── AbstractForbiddenWordSniff.php
│ │ ├── ForbiddenWordCourseSniff.php
│ │ └── ForbiddenWordTreatmentMenuSniff.php
│ └── DateTime/
│ └── DateTimeCreateFromTimestampSniff.php
├── composer.json
└── phpcs.xml # Project PHPCS configuration
The directory structure directly maps to the namespace:
- Standard name:
AppSniff - Namespace:
AppSniff\Sniffs\ForbiddenWord - File path:
php-rules/AppSniff/Sniffs/ForbiddenWord/
Set up autoloading in composer.json:
{
"autoload-dev": {
"psr-4": {
"AppSniff\\": "php-rules/AppSniff/"
}
}
}
Then register the custom standard path in your project’s phpcs.xml:
<ruleset name="Project">
<!-- Add custom standard path -->
<config name="installed_paths" value="php-rules/AppSniff" />
<!-- Apply custom standard -->
<rule ref="AppSniff" />
</ruleset>
PHPStan Custom Rule Structure
PHPStan custom rules are regular PHP classes registered via a neon file:
project-root/
├── php-rules/
│ └── phpstan/
│ ├── rules/
│ │ └── Domain/
│ │ └── ForbiddenCoreDomainDependencyRule.php
│ └── extension.neon # Custom rule definitions
├── composer.json
└── phpstan.neon # Project PHPStan configuration
The namespace is up to you, but placing it under the project namespace feels natural:
{
"autoload-dev": {
"psr-4": {
"App\\PHPStan\\": "php-rules/phpstan/"
}
}
}
Load the custom rule neon file from phpstan.neon:
includes:
- php-rules/phpstan/extension.neon
PHPCS has strict naming conventions (directory name = standard name, class name must end with *Sniff), while PHPStan gives you more freedom. The AI agent handles these “implicit rules” automatically, which is nice.
Example: Custom PHPCS Sniffs
Here are some custom Sniffs actually running in production.
Forbidden Word Detection
After renaming domain terms, old names left in code cause confusion. This Sniff catches them.
The base class:
<?php
declare(strict_types=1);
namespace AppSniff\Sniffs\ForbiddenWord;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
abstract class AbstractForbiddenWordSniff implements Sniff
{
protected string $forbiddenWord = '';
protected bool $allowInComments = false;
public function register(): array
{
return [
T_STRING,
T_CONSTANT_ENCAPSED_STRING,
T_DOUBLE_QUOTED_STRING,
T_COMMENT,
T_DOC_COMMENT,
];
}
public function process(File $phpcsFile, $stackPtr): void
{
$tokens = $phpcsFile->getTokens();
$content = $tokens[$stackPtr]['content'];
// Skip comment tokens if allowed in comments
if ($this->allowInComments === true) {
$tokenType = $tokens[$stackPtr]['code'];
if (in_array($tokenType, [T_COMMENT, T_DOC_COMMENT], true)) {
return;
}
}
if (stripos($content, $this->forbiddenWord) !== false) {
$phpcsFile->addError(
'Usage of the word "%s" is forbidden',
$stackPtr,
'ForbiddenWord',
[$this->forbiddenWord]
);
}
}
}
Adding a new forbidden word is just this:
class ForbiddenWordCourseSniff extends AbstractForbiddenWordSniff
{
protected string $forbiddenWord = 'コース';
}
Need another one that allows the word in comments? Just set the flag:
class ForbiddenWordTreatmentMenuSniff extends AbstractForbiddenWordSniff
{
protected string $forbiddenWord = 'サービスメニュー';
protected bool $allowInComments = true;
}
Exclude directories via ruleset.xml:
<ruleset name="AppSniff">
<description>App custom coding standard</description>
<rule ref="AppSniff.ForbiddenWord">
<exclude-pattern>*/config/Migrations/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>
</ruleset>
Method Argument Enforcement
CakePHP’s DateTime::createFromTimestamp() uses the server’s default timezone when the second argument is omitted. This works locally but causes subtle time-shift bugs in production.
This Sniff flags calls missing the timezone argument:
class DateTimeCreateFromTimestampSniff implements Sniff
{
public function register(): array
{
return [T_DOUBLE_COLON];
}
public function process(File $phpcsFile, $stackPtr): void
{
// (snip)
// Resolves fully qualified class name considering use statements
// Checks if it's \Cake\I18n\DateTime::createFromTimestamp()
if ($argumentCount <= 1) {
$phpcsFile->addError(
'\Cake\I18n\DateTime::createFromTimestamp() requires '
. 'a second argument (timezone)',
$stackPtr,
'MissingSecondArgument'
);
}
}
}
Example: Custom PHPStan Rule
Core Domain Dependency Constraint
In a DDD project, this rule ensures core domain classes (App\Domain\*\Core) don’t depend on external packages or infrastructure layers.
class ForbiddenCoreDomainDependencyRule implements Rule
{
public function __construct(
private readonly array $allowedDependencies = [],
private readonly array $commonAllowedDependencies = []
) {
}
public function getNodeType(): string
{
return Node::class;
}
public function processNode(Node $node, Scope $scope): array
{
$currentNamespace = $scope->getNamespace();
if (!$this->isCoreDomain($currentNamespace)) {
return [];
}
// Checks new, ::, implements, use statements
// Allows PHP built-ins, same domain, and allowlisted namespaces
return [
RuleErrorBuilder::message(
sprintf('Core domain %s must not depend on %s.',
$currentNamespace, $className)
)->identifier('app.forbiddenCoreDomainDependency')->build()
];
}
}
The allow list is managed in a neon file:
parameters:
forbiddenCoreDomain:
commonAllowedDependencies:
- App\Domain\Core
- Psr\Log
allowedDependencies:
Billing:
- App\Domain\Payment
- App\Domain\Tax
Reservation:
- App\Domain\Customer
services:
-
class: App\PHPStan\Rules\Domain\ForbiddenCoreDomainDependencyRule
tags:
- phpstan.rules.rule
arguments:
allowedDependencies: %forbiddenCoreDomain.allowedDependencies%
commonAllowedDependencies: %forbiddenCoreDomain.commonAllowedDependencies%
Summary
This kind of developer tooling doesn’t need to be perfect, which makes it a great fit for Vibe Coding.
With production code you’d want to review everything carefully, but with developer tools, a few gaps here and there are totally fine.
Building these rules up over time means your project’s constraints are explicitly managed in code, which reduces review costs. Highly recommended.
…I just remembered I wanted to talk about this at a conference once. Consider this its memorial.