Using PHPStan to Enforce Project-Level Rules
Your codebase is too large to fit in any Large Language Model (LLM) context window. Even with Claude Sonnet 4.5's 200,000 token window, Gemini 2.5 Pro's 1 million tokens (expanding to 2 million), or GPT-4.1's 1 million tokens, large-scale applications exceed these limits. Static analysis tools like PHPStan work differently - they analyse your entire codebase systematically, enforcing rules that are cheap (CPU cycles, not tokens), deterministic, and comprehensive. This article explores how to write custom PHPStan rules that codify your project's unique standards, making them automatic, consistent, and educational.
The Context Window Problem
Modern LLMs are powerful, but they have fundamental limitations when analysing large codebases. A typical enterprise application contains millions of lines of code spread across thousands of files. Even with aggressive compression, this exceeds any context window.
PHPStan solves this by using Abstract Syntax Trees (AST) and type inference. It doesn't need to "understand" your code like an LLM - it systematically checks every node in the PHP-Parser AST against your rules. This approach:
- Scales linearly with codebase size
- Runs in CI/CD with consistent, reproducible results
- Costs pennies in compute time vs. dollars in LLM tokens
- Catches violations before code review
- Documents standards through executable rules
Types of Project-Level Rules
Custom PHPStan rules fall into several categories, each addressing different aspects of code quality:
Performance Rules
Detect anti-patterns that cause performance problems. These are often subtle issues that only manifest at scale, like N+1 query problems or inefficient algorithms in hot paths.
Architectural Rules
Enforce design decisions and boundaries. For example, preventing business logic in destructors, ensuring proper dependency injection (PSR-11), or maintaining layered architecture boundaries.
Security Rules
Catch security vulnerabilities before they reach production. Examples include detecting SQL injection risks, unvalidated user input, or insecure cryptographic practices.
Testing Rules
Enforce test quality standards. Prevent brittle tests that mock critical services like databases, ensure proper test isolation, and verify that tests actually exercise production code paths.
Code Quality Rules
Eliminate common maintainability issues. Examples include detecting magic strings, enforcing naming conventions, or requiring proper documentation.
Anatomy of a PHPStan Rule
Every PHPStan rule implements the PHPStan\Rules\Rule
interface with two methods:
getNodeType()- Returns the AST node type to monitorprocessNode()- Analyses nodes and returns errors if violations are found
Here's the basic structure:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Basic PHPStan rule structure demonstrating the core interface requirements.
*
* Every PHPStan rule must implement the Rule interface with two methods:
* 1. getNodeType() - Specifies which AST node types to examine
* 2. processNode() - Analyses the node and returns errors if violations found
*/
final class ExampleRule implements Rule
{
/**
* Returns the AST node type this rule monitors.
*
* Common node types:
* - Node\Expr\New_::class (new ClassName())
* - Node\Expr\MethodCall::class ($object->method())
* - Node\Expr\StaticCall::class (Class::method())
* - Node\Stmt\ClassMethod::class (method definitions)
* - Node\Expr\FuncCall::class (function calls)
*/
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
/**
* Processes each node of the specified type and returns errors.
*
* @param Node $node The AST node being examined
* @param Scope $scope The analysis scope providing type and context information
* @return array<\PHPStan\Rules\RuleError> Array of rule errors
*/
public function processNode(Node $node, Scope $scope): array
{
// Type check is required since Rule interface uses covariant return types
if (!$node instanceof Node\Expr\MethodCall) {
return [];
}
// Access node properties to examine the code
$methodName = $node->name;
// Skip if method name isn't a simple identifier
if (!$methodName instanceof Node\Identifier) {
return [];
}
// Example: Detect calls to a problematic method
if ($methodName->toString() === 'dangerousMethod') {
return [
RuleErrorBuilder::message(
'Calling dangerousMethod() is not allowed. Use safeMethod() instead.'
)
->identifier('app.dangerousMethodCall')
->line($node->getStartLine())
->tip('Replace with: $object->safeMethod()')
->build()
];
}
// No violations found
return [];
}
}
The getNodeType() method tells PHPStan which AST nodes
you want to examine. Common node types include:
Node\Expr\New_- Object instantiation (new ClassName())Node\Expr\MethodCall- Method calls ($object->method())Node\Expr\StaticCall- Static calls (Class::method())Node\Stmt\ClassMethod- Method definitionsNode\Expr\FuncCall- Function calls
The processNode() method receives each matching node along with a
Scope object that provides
rich context about the code's location, types, and surrounding structure.
Real-World Example: Performance Rules
Detecting Queries in Loops
One of the most common performance killers is the N+1 query problem -
executing database queries inside loops. This rule detects when Query objects are instantiated within
foreach,
for,
while, or
do-while loops:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\Performance;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
/**
* Detects database query instantiation inside loops.
*
* This is a common performance anti-pattern where queries are created
* inside foreach, for, while, or do-while loops. This typically leads
* to N+1 query problems.
*
* Example violation:
* foreach ($users as $user) {
* $query = new Query('SELECT * FROM orders WHERE user_id = ?');
* }
*
* @implements Rule<Node\Expr\New_>
*/
final class QueryInLoopRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\New_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof Node\Expr\New_) {
return [];
}
// Check if we're instantiating a Query class
if (!$node->class instanceof Node\Name) {
return [];
}
$className = $scope->resolveName($node->class);
$queryType = new ObjectType('App\\Database\\Query');
// Check if the instantiated class is a Query type
if (!$queryType->isSuperTypeOf(new ObjectType($className))->yes()) {
return [];
}
// Check if we're inside a loop
if (!$this->isInsideLoop($node)) {
return [];
}
return [
RuleErrorBuilder::message(
'Query instantiation detected inside a loop. ' .
'This creates N+1 query problems and severe performance degradation.'
)
->identifier('app.queryInLoop')
->line($node->getStartLine())
->tip(
'Refactor to: ' . PHP_EOL .
'1. Build a list of IDs in the loop' . PHP_EOL .
'2. Execute a single query with WHERE id IN (...)' . PHP_EOL .
'3. Map results back to the original data' . PHP_EOL .
'See: https://your-docs.example.com/performance/query-batching'
)
->build()
];
}
/**
* Traverses up the AST tree to determine if the node is inside a loop.
*/
private function isInsideLoop(Node $node): bool
{
$parent = $node->getAttribute('parent');
while ($parent !== null) {
if ($parent instanceof Node\Stmt\Foreach_
|| $parent instanceof Node\Stmt\For_
|| $parent instanceof Node\Stmt\While_
|| $parent instanceof Node\Stmt\Do_
) {
return true;
}
$parent = $parent->getAttribute('parent');
}
return false;
}
}
This rule catches code like this:
// ❌ VIOLATES RULE - Query created inside loop (N+1 problem)
foreach ($users as $user) {
$query = new ProductQuery(); // PHPStan error!
$products = $query->where('user_id', $user->id)->execute();
// Process products...
}
Instead, write:
// ✅ PASSES RULE - Query created once, batched execution
$userIds = array_map(fn($u) => $u->id, $users);
$query = new ProductQuery(); // Create once before loop
$allProducts = $query->whereIn('user_id', $userIds)->execute();
// Map products back to users
foreach ($users as $user) {
$userProducts = array_filter($allProducts, fn($p) => $p->user_id === $user->id);
// Process products...
}
This rule uses PHPStan's type system
to identify Query instantiations and traverses the AST upward to detect loop contexts. The error message is
educational, explaining the problem and providing concrete guidance on how to fix it.
Real-World Example: Code Quality Rules
Eliminating Magic Strings
Magic strings are string literals embedded directly in code rather than defined as constants. They make refactoring difficult and are prone to typos. This rule enforces using class constants for command names:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\CodeQuality;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
/**
* Enforces using constants instead of magic strings for command names.
*
* Magic strings make refactoring difficult and prone to typos. This rule
* ensures command names are defined as class constants.
*
* Example violation:
* $this->executeCommand('user:activate'); // Magic string
*
* Correct approach:
* $this->executeCommand(Commands::USER_ACTIVATE); // Type-safe constant
*
* @implements Rule<Node\Expr\MethodCall>
*/
final class NoMagicStringCommandsRule implements Rule
{
private const COMMAND_METHODS = [
'executeCommand',
'queueCommand',
'dispatchCommand',
];
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof Node\Expr\MethodCall) {
return [];
}
// Check if this is a command execution method
if (!$node->name instanceof Node\Identifier) {
return [];
}
$methodName = $node->name->toString();
if (!in_array($methodName, self::COMMAND_METHODS, true)) {
return [];
}
// Check if first argument is a string literal
if (count($node->getArgs()) === 0) {
return [];
}
$firstArg = $node->getArgs()[0]->value;
// If it's a string literal, that's a violation
if ($firstArg instanceof Node\Scalar\String_) {
$commandName = $firstArg->value;
return [
RuleErrorBuilder::message(
sprintf(
'Magic string "%s" used for command name. ' .
'Use a class constant from the Commands class instead.',
$commandName
)
)
->identifier('app.magicStringCommand')
->line($node->getStartLine())
->tip(
sprintf(
'Replace with: %s(Commands::YOUR_COMMAND)' . PHP_EOL .
'Define constants in: src/Commands.php' . PHP_EOL .
'Benefits: Type safety, IDE autocomplete, refactoring support',
$methodName
)
)
->build()
];
}
return [];
}
}
This rule catches code like this:
// ❌ VIOLATES RULE
$application->execute('sync:users'); // PHPStan error!
Instead, write:
// ✅ PASSES RULE - use command class constant
$application->execute(SyncUsersCommand::COMMAND_NAME);
By detecting string literals passed to command execution methods, this rule forces developers to use type-safe constants. This provides IDE autocomplete, prevents typos, and makes refactoring straightforward.
Real-World Example: Architectural Rules
Preventing Work in Destructors
PHP destructors
(__destruct()) are called during object cleanup, and their execution timing is unpredictable - they depend
on garbage collection.
Performing I/O or business logic in destructors leads to race conditions and unpredictable behaviour:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\Architecture;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Prevents any work from being performed in class destructors.
*
* Destructors are called during object cleanup and their execution timing
* is unpredictable. They should never perform I/O, call external services,
* or execute business logic.
*
* This is an architectural rule that enforces clean separation of concerns.
*
* @implements Rule<Node\Stmt\ClassMethod>
*/
final class NoWorkInDestructorsRule implements Rule
{
public function getNodeType(): string
{
return Node\Stmt\ClassMethod::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof Node\Stmt\ClassMethod) {
return [];
}
// Only check destructors
if ($node->name->toString() !== '__destruct') {
return [];
}
// Check if destructor has any statements
if ($node->stmts === null || count($node->stmts) === 0) {
return [];
}
// Filter out comments and empty statements
$realStatements = array_filter(
$node->stmts,
fn($stmt) => !($stmt instanceof Node\Stmt\Nop)
);
if (count($realStatements) === 0) {
return [];
}
return [
RuleErrorBuilder::message(
'Destructors must not contain any logic. ' .
'Destructor execution timing is unpredictable and depends on garbage collection.'
)
->identifier('app.workInDestructor')
->line($node->getStartLine())
->tip(
'Architectural guidelines:' . PHP_EOL .
'1. Never perform I/O in destructors (database, files, network)' . PHP_EOL .
'2. Never call external services' . PHP_EOL .
'3. Never execute business logic' . PHP_EOL .
'4. Use explicit cleanup methods instead (e.g., close(), dispose())' . PHP_EOL .
'See: https://www.php.net/manual/en/language.oop5.decon.php'
)
->build()
];
}
}
This rule catches code like this:
// ❌ VIOLATES RULE - I/O work in destructor
class Logger {
public function __destruct() {
$this->fileHandle->flush(); // PHPStan error!
fclose($this->fileHandle); // Unpredictable timing
}
}
Instead, write:
// ✅ PASSES RULE - destructor only verifies cleanup
class FileLogger {
private bool $closed = false;
public function close(): void {
fflush($this->handle);
fclose($this->handle);
$this->closed = true;
}
public function __destruct() {
if (!$this->closed) {
throw new LogicException(
'FileLogger not closed. Call close() explicitly.'
);
}
}
}
This architectural rule enforces a best practice: destructors should only verify that cleanup was done, not perform the
cleanup itself. The rule allows throwing LogicException
to catch missing cleanup, but any actual I/O work should happen in explicit methods like close() or dispose(),
giving developers control over when resources are released.
Enforcing Dependency Injection
Direct access to environment variables via getenv()
or $_ENV
violates dependency injection principles:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\Architecture;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Prevents direct access to environment variables using getenv() or $_ENV.
*
* Direct environment access violates dependency injection principles and
* makes code harder to test. Configuration should be injected through
* constructors, making dependencies explicit.
*
* This rule enforces proper dependency injection patterns.
*
* @implements Rule<Node>
*/
final class NoDirectEnvAccessRule implements Rule
{
public function getNodeType(): string
{
return Node::class;
}
public function processNode(Node $node, Scope $scope): array
{
$errors = [];
// Check for getenv() calls
if ($node instanceof Node\Expr\FuncCall
&& $node->name instanceof Node\Name
&& $node->name->toString() === 'getenv'
) {
$errors[] = RuleErrorBuilder::message(
'Direct environment access via getenv() is not allowed. ' .
'Use dependency injection to pass configuration.'
)
->identifier('app.directEnvAccess')
->line($node->getStartLine())
->tip(
'Best practice:' . PHP_EOL .
'1. Create a configuration class that reads from environment' . PHP_EOL .
'2. Register it in your dependency injection container' . PHP_EOL .
'3. Inject the configuration into classes that need it' . PHP_EOL .
'Benefits: Testability, explicit dependencies, type safety'
)
->build();
}
// Check for $_ENV access
if ($node instanceof Node\Expr\ArrayDimFetch
&& $node->var instanceof Node\Expr\Variable
&& is_string($node->var->name)
&& $node->var->name === '_ENV'
) {
$errors[] = RuleErrorBuilder::message(
'Direct environment access via $_ENV is not allowed. ' .
'Use dependency injection to pass configuration.'
)
->identifier('app.directEnvAccess')
->line($node->getStartLine())
->tip(
'Replace with constructor injection:' . PHP_EOL .
'public function __construct(private readonly ConfigInterface $config) {}' . PHP_EOL .
'Then use: $this->config->get(\'KEY\')'
)
->build();
}
// Check for $_SERVER access (common for environment variables)
if ($node instanceof Node\Expr\ArrayDimFetch
&& $node->var instanceof Node\Expr\Variable
&& is_string($node->var->name)
&& $node->var->name === '_SERVER'
) {
// Only flag if accessing typical environment variable keys
if ($node->dim instanceof Node\Scalar\String_) {
$key = $node->dim->value;
$envLikePatterns = [
'APP_',
'DB_',
'CACHE_',
'API_',
'SECRET_',
];
foreach ($envLikePatterns as $pattern) {
if (str_starts_with($key, $pattern)) {
$errors[] = RuleErrorBuilder::message(
sprintf(
'Direct environment access via $_SERVER[\'%s\'] is not allowed. ' .
'Use dependency injection to pass configuration.',
$key
)
)
->identifier('app.directEnvAccess')
->line($node->getStartLine())
->build();
break;
}
}
}
}
return $errors;
}
}
This rule catches code like this:
// ❌ VIOLATES RULE - Direct environment access
class EmailService {
public function send(): void {
$apiKey = getenv('MAILGUN_API_KEY'); // PHPStan error!
// Send email using $apiKey...
}
}
Instead, write:
// ✅ PASSES RULE - Constructor injection
class EmailService {
public function __construct(
private readonly string $mailgunApiKey
) {}
public function send(): void {
// Use $this->mailgunApiKey - testable, explicit
}
}
This rule enforces proper PSR-11 dependency injection, making dependencies explicit and code testable. Configuration should flow through constructor injection, not be pulled from global state.
Real-World Example: Rules for Tests
Preventing Mocks of Critical Services
Mocking is useful, but mocking critical infrastructure like database services produces false confidence. These components should be tested against real (test) databases:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\Testing;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\ObjectType;
/**
* Prevents mocking of the DatabaseService in tests.
*
* The DatabaseService is a critical infrastructure component that should
* be tested against a real test database, not mocked. Mocking it produces
* false confidence and hides real integration issues.
*
* This is a test safety rule that enforces proper integration testing.
*
* @implements Rule<Node\Expr\MethodCall>
*/
final class NoMockDatabaseServiceRule implements Rule
{
public function getNodeType(): string
{
return Node\Expr\MethodCall::class;
}
public function processNode(Node $node, Scope $scope): array
{
if (!$node instanceof Node\Expr\MethodCall) {
return [];
}
// Only check code in test files
if (!str_ends_with($scope->getFile(), 'Test.php')) {
return [];
}
// Check if this is a createMock() or getMockBuilder() call
if (!$node->name instanceof Node\Identifier) {
return [];
}
$methodName = $node->name->toString();
if (!in_array($methodName, ['createMock', 'getMockBuilder'], true)) {
return [];
}
// Check if we're mocking DatabaseServiceInterface
if (count($node->getArgs()) === 0) {
return [];
}
$firstArg = $node->getArgs()[0]->value;
// Check for DatabaseServiceInterface::class
if ($firstArg instanceof Node\Expr\ClassConstFetch
&& $firstArg->class instanceof Node\Name
&& $firstArg->name instanceof Node\Identifier
&& $firstArg->name->toString() === 'class'
) {
$className = $scope->resolveName($firstArg->class);
// Check if it's the DatabaseServiceInterface
if ($className === 'App\\Database\\DatabaseServiceInterface'
|| str_ends_with($className, '\\DatabaseServiceInterface')
) {
return [
RuleErrorBuilder::message(
'Mocking DatabaseServiceInterface is not allowed. ' .
'This is a critical service that must be tested against a real test database.'
)
->identifier('app.mockDatabaseService')
->line($node->getStartLine())
->tip(
'Testing guidelines:' . PHP_EOL .
'1. Use TestCase::setUpDatabase() to get a real test database' . PHP_EOL .
'2. Tests run in transactions that auto-rollback' . PHP_EOL .
'3. Test database is isolated and uses test fixtures' . PHP_EOL .
'4. Integration tests provide real confidence' . PHP_EOL .
'See: docs/testing/database-testing.md'
)
->build()
];
}
}
return [];
}
}
This rule catches code like this:
// ❌ VIOLATES RULE - Mocking critical database service
class UserRepositoryTest extends TestCase {
public function testGetUser(): void {
$mockDb = $this->createMock(DatabaseServiceInterface::class); // PHPStan error!
$mockDb->method('query')->willReturn(['id' => 1]);
// False confidence - not testing real database behaviour
}
}
Instead, write:
// ✅ PASSES RULE - Real database integration test
class UserRepositoryTest extends TestCase {
private DatabaseServiceInterface $db;
protected function setUp(): void {
$this->db = new TestDatabaseService(); // Real test database
$this->db->beginTransaction();
}
public function testGetUser(): void {
// Test against real database - catches transaction issues, etc.
}
}
This rule scans test files for PHPUnit mock creation
and blocks attempts to mock DatabaseServiceInterface. Integration tests that use real databases catch
issues that mocks hide, like transaction handling,
isolation levels, and
query performance.
Enforcing Test Isolation
Tests should never reference production table names directly. This couples tests to production schema details and makes refactoring dangerous:
<?php
declare(strict_types=1);
namespace App\PHPStan\Rules\Testing;
use PhpParser\Node;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* Prevents references to production table names in test files.
*
* Tests should use test-specific table fixtures, not production table names.
* This ensures tests are isolated and don't accidentally depend on
* production schema details.
*
* This is a test isolation rule that enforces proper test data management.
*
* @implements Rule<Node\Scalar\String_>
*/
final class NoProductionTablesInTestsRule implements Rule
{
/**
* List of production table names that should not appear in tests.
*/
private const PRODUCTION_TABLES = [
'users',
'orders',
'products',
'customers',
'invoices',
'payments',
'transactions',
'accounts',
];
public function getNodeType(): string
{
return Node\Scalar\String_::class;
}
public function processNode(Node $node, Scope $scope): array
{
// Only check code in test files
if (!str_ends_with($scope->getFile(), 'Test.php')) {
return [];
}
if (!$node instanceof Node\Scalar\String_) {
return [];
}
$stringValue = strtolower($node->value);
// Check if the string matches a production table name
foreach (self::PRODUCTION_TABLES as $tableName) {
// Match exact table name or in SQL-like context
if ($stringValue === $tableName
|| str_contains($stringValue, "from {$tableName}")
|| str_contains($stringValue, "from `{$tableName}`")
|| str_contains($stringValue, "join {$tableName}")
|| str_contains($stringValue, "join `{$tableName}`")
|| str_contains($stringValue, "into {$tableName}")
|| str_contains($stringValue, "into `{$tableName}`")
|| str_contains($stringValue, "table {$tableName}")
|| str_contains($stringValue, "table `{$tableName}`")
) {
return [
RuleErrorBuilder::message(
sprintf(
'Production table name "%s" detected in test file. ' .
'Tests must use test-specific fixtures.',
$tableName
)
)
->identifier('app.productionTableInTest')
->line($node->getStartLine())
->tip(
'Test isolation guidelines:' . PHP_EOL .
'1. Use test fixtures with "test_" prefix (e.g., "test_users")' . PHP_EOL .
'2. Load fixtures via TestCase::loadFixtures()' . PHP_EOL .
'3. Never depend on production schema details' . PHP_EOL .
'4. Keep tests isolated and repeatable' . PHP_EOL .
'See: docs/testing/fixtures.md'
)
->build()
];
}
}
return [];
}
}
This rule catches code like this:
// ❌ VIOLATES RULE - Production table name in test
class OrderTest extends TestCase {
public function testCreateOrder(): void {
$result = $this->db->query('SELECT * FROM orders WHERE id = ?', [1]); // PHPStan error!
// Coupled to production schema
}
}
Instead, write:
// ✅ PASSES RULE - Test-specific table name
class OrderTest extends TestCase {
public function testCreateOrder(): void {
$result = $this->db->query('SELECT * FROM test_orders WHERE id = ?', [1]);
// Isolated from production schema changes
}
}
By detecting production table names in string literals
within test files, this rule enforces proper fixture usage. Tests should use test-specific tables (like test_users)
that are isolated from production data and schema changes.
Configuration and Registration
Once you've written your rules, register them in your PHPStan configuration file
(phpstan.neon or phpstan.yaml):
parameters:
# Analysis level (0-10, where 10 is strictest with mixed type enforcement)
level: 10
# Paths to analyze
paths:
- src
- tests
# Exclude patterns
excludePaths:
- src/Legacy/*
- tests/fixtures/*
# PHPStan 2.0+ features
treatPhpDocTypesAsCertain: false
# Report all possibly undefined variables
checkMaybeUndefinedVariables: true
# Check for missing type hints
checkMissingIterableValueType: true
# Register custom rules
rules:
# Performance rules
- App\PHPStan\Rules\Performance\QueryInLoopRule
# Code quality rules
- App\PHPStan\Rules\CodeQuality\NoMagicStringCommandsRule
# Architectural rules
- App\PHPStan\Rules\Architecture\NoWorkInDestructorsRule
- App\PHPStan\Rules\Architecture\NoDirectEnvAccessRule
# Testing rules
- App\PHPStan\Rules\Testing\NoMockDatabaseServiceRule
- App\PHPStan\Rules\Testing\NoProductionTablesInTestsRule
# Include extension config files
includes:
- phpstan-baseline.neon
- vendor/phpstan/phpstan-phpunit/extension.neon
- vendor/phpstan/phpstan-strict-rules/rules.neon
services:
# Register rules with constructor dependencies
-
class: App\PHPStan\Rules\Performance\QueryInLoopRule
tags:
- phpstan.rules.rule
# Example: Rule with configuration
-
class: App\PHPStan\Rules\CodeQuality\NoMagicStringCommandsRule
arguments:
allowedMethods: ['executeCommand', 'queueCommand', 'dispatchCommand']
tags:
- phpstan.rules.rule
PHPStan 2.0 (released 31 December 2024)
introduced Level 10,
which treats the mixed type
strictly and reduced memory consumption by 50-70%. The current version is
2.1.31 (released 10 October 2025).
Testing Your Rules
PHPStan provides PHPStan\Testing\RuleTestCase
for testing custom rules. Here's a simple test structure:
*/
final class QueryInLoopRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new QueryInLoopRule();
}
public function testRule(): void
{
$this->analyse(
[__DIR__ . '/data/query-in-loop.php'],
[
[
'Query instantiation detected inside a loop.',
15, // Line number
],
]
);
}
public function testNoErrorsWhenQueryOutsideLoop(): void
{
$this->analyse(
[__DIR__ . '/data/query-outside-loop.php'],
[] // No errors expected
);
}
}
The PHPUnit-based test framework makes it easy to verify your rules work correctly with both positive (should error) and negative (should pass) test cases.
Educational Error Messages
The most powerful aspect of custom rules is their error messages. They're not just alerts - they're teachable moments. Good error messages should:
- Explain the problem - Why is this code flagged?
- Provide context - What are the consequences?
- Offer solutions - How should developers fix it?
- Link to documentation - Where can they learn more?
Using RuleErrorBuilder,
you can create rich error messages with tips and identifiers:
RuleErrorBuilder::message(
'Query instantiation detected inside a loop. ' .
'This creates N+1 query problems and severe performance degradation.'
)
->identifier('app.queryInLoop')
->line($node->getStartLine())
->tip(
'Refactor to:' . PHP_EOL .
'1. Build a list of IDs in the loop' . PHP_EOL .
'2. Execute a single query with WHERE id IN (...)' . PHP_EOL .
'3. Map results back to the original data' . PHP_EOL .
'See: https://your-docs.example.com/performance/query-batching'
)
->build()
The identifier() method provides a machine-readable error code that can be used for
targeted suppression
or reporting. The tip() method adds actionable guidance that appears in IDE tooltips and CI output.
Static Analysis in Other Languages
Custom static analysis rules aren't unique to PHP. Every mature language ecosystem provides tools for enforcing project-specific standards:
JavaScript/TypeScript: ESLint
ESLint allows creating custom rules that analyse JavaScript and TypeScript code. The API uses ESTree AST nodes:
/**
* ESLint custom rule: no-queries-in-loops
*
* Detects database query execution inside loops, similar to the PHPStan rule.
* This is a common performance anti-pattern in Node.js/TypeScript applications.
*/
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'Disallow database queries inside loops',
category: 'Performance',
recommended: true,
url: 'https://your-docs.example.com/rules/no-queries-in-loops'
},
messages: {
queryInLoop: 'Database query detected inside a loop ({{loopType}}). This creates N+1 query problems. Refactor to batch queries outside the loop.',
},
schema: []
},
create(context) {
// Track loop nesting
let loopDepth = 0;
const loopStack = [];
// Query method patterns to detect
const queryMethods = new Set([
'query',
'execute',
'find',
'findOne',
'findMany',
'create',
'update',
'delete'
]);
return {
// Track entering loops
'ForStatement, ForInStatement, ForOfStatement, WhileStatement, DoWhileStatement': (node) => {
loopDepth++;
loopStack.push(node.type);
},
// Track exiting loops
'ForStatement:exit, ForInStatement:exit, ForOfStatement:exit, WhileStatement:exit, DoWhileStatement:exit': () => {
loopDepth--;
loopStack.pop();
},
// Check for query method calls
CallExpression(node) {
// Only check if we're inside a loop
if (loopDepth === 0) {
return;
}
// Check for await db.query(), db.execute(), etc.
if (node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
queryMethods.has(node.callee.property.name)) {
// Check if the object is likely a database connection
const objectName = getObjectName(node.callee.object);
if (isDatabaseObject(objectName)) {
context.report({
node,
messageId: 'queryInLoop',
data: {
loopType: loopStack[loopStack.length - 1]
}
});
}
}
}
};
function getObjectName(node) {
if (node.type === 'Identifier') {
return node.name;
}
if (node.type === 'MemberExpression' && node.property.type === 'Identifier') {
return node.property.name;
}
return null;
}
function isDatabaseObject(name) {
if (!name) return false;
const dbPatterns = ['db', 'database', 'connection', 'conn', 'client'];
const lowerName = name.toLowerCase();
return dbPatterns.some(pattern => lowerName.includes(pattern));
}
}
};
For TypeScript-specific rules, typescript-eslint provides enhanced APIs with access to TypeScript's compiler API for type-aware analysis.
Python: Pylint
Pylint supports custom checkers that analyse Python code using the astroid library:
"""
Pylint custom checker: query-in-loop
Detects database query execution inside loops in Python code.
This is a common performance anti-pattern that leads to N+1 query problems.
"""
from typing import TYPE_CHECKING
from astroid import nodes
from pylint.checkers import BaseChecker
if TYPE_CHECKING:
from pylint.lint import PyLinter
class QueryInLoopChecker(BaseChecker):
"""Checker for detecting database queries inside loops."""
name = "query-in-loop"
msgs = {
"W9001": (
"Database query detected inside a loop (%s). "
"This creates N+1 query problems and severe performance degradation.",
"query-in-loop",
"Refactor to batch queries outside the loop using WHERE IN clauses "
"or bulk operations.",
),
}
# Query method patterns commonly used in Python ORMs
QUERY_METHODS = {
"execute",
"fetchone",
"fetchall",
"fetchmany",
"query",
"filter",
"get",
"first",
"all",
"create",
"update",
"delete",
}
def __init__(self, linter: "PyLinter") -> None:
super().__init__(linter)
self._loop_depth = 0
def visit_for(self, node: nodes.For) -> None:
"""Track entering a for loop."""
self._loop_depth += 1
def leave_for(self, node: nodes.For) -> None:
"""Track exiting a for loop."""
self._loop_depth -= 1
def visit_while(self, node: nodes.While) -> None:
"""Track entering a while loop."""
self._loop_depth += 1
def leave_while(self, node: nodes.While) -> None:
"""Track exiting a while loop."""
self._loop_depth -= 1
def visit_call(self, node: nodes.Call) -> None:
"""Check for database query calls inside loops."""
# Only check if we're inside a loop
if self._loop_depth == 0:
return
# Check if this is a method call
if not isinstance(node.func, nodes.Attribute):
return
method_name = node.func.attrname
# Check if it's a query method
if method_name not in self.QUERY_METHODS:
return
# Try to determine if the object is database-related
if self._is_database_object(node.func.expr):
loop_type = self._get_loop_type(node)
self.add_message("query-in-loop", node=node, args=(loop_type,))
def _is_database_object(self, node: nodes.NodeNG) -> bool:
"""
Heuristic to determine if an object is database-related.
Checks variable names and types for common patterns.
"""
# Check for common database object names
db_patterns = ["db", "session", "connection", "conn", "cursor", "query"]
if isinstance(node, nodes.Name):
name_lower = node.name.lower()
return any(pattern in name_lower for pattern in db_patterns)
if isinstance(node, nodes.Attribute):
attr_lower = node.attrname.lower()
return any(pattern in attr_lower for pattern in db_patterns)
return False
def _get_loop_type(self, node: nodes.NodeNG) -> str:
"""Determine the type of loop containing the query."""
parent = node.parent
while parent:
if isinstance(parent, nodes.For):
return "for loop"
if isinstance(parent, nodes.While):
return "while loop"
parent = parent.parent
return "loop"
def register(linter: "PyLinter") -> None:
"""Register the checker with Pylint."""
linter.register_checker(QueryInLoopChecker(linter))
Pylint's checker system supports AST checkers, raw checkers (for line-by-line analysis), and token checkers.
Go: go/analysis
Go's go/analysis
package provides a standard framework for building custom analysers:
// Package queryinloop implements a Go analyzer to detect database queries in loops.
//
// This analyzer identifies patterns where database operations are executed
// inside loops, which can lead to N+1 query problems and performance issues.
package queryinloop
import (
"go/ast"
"strings"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"
)
// Analyzer detects database queries inside loops.
var Analyzer = &analysis.Analyzer{
Name: "queryinloop",
Doc: "check for database queries inside loops",
Run: run,
Requires: []*analysis.Analyzer{inspect.Analyzer},
}
// Common database method names to detect
var queryMethods = map[string]bool{
"Query": true,
"QueryRow": true,
"Exec": true,
"Execute": true,
"Find": true,
"FindOne": true,
"Create": true,
"Update": true,
"Delete": true,
"Get": true,
"GetOne": true,
"Insert": true,
}
func run(pass *analysis.Pass) (interface{}, error) {
inspect := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
// Track loop nesting
loopDepth := 0
// Define node filter for performance
nodeFilter := []ast.Node{
(*ast.ForStmt)(nil),
(*ast.RangeStmt)(nil),
(*ast.CallExpr)(nil),
}
inspect.Preorder(nodeFilter, func(n ast.Node) {
switch n := n.(type) {
case *ast.ForStmt, *ast.RangeStmt:
loopDepth++
defer func() { loopDepth-- }()
case *ast.CallExpr:
// Only check if we're inside a loop
if loopDepth == 0 {
return
}
// Check if this is a method call
selector, ok := n.Fun.(*ast.SelectorExpr)
if !ok {
return
}
// Check if the method name suggests a database operation
methodName := selector.Sel.Name
if !queryMethods[methodName] {
return
}
// Check if the receiver looks like a database object
if isDatabaseObject(selector.X) {
pass.Reportf(n.Pos(),
"database query '%s' detected inside a loop; "+
"this creates N+1 query problems and performance issues; "+
"refactor to batch queries using WHERE IN or similar patterns",
methodName)
}
}
})
return nil, nil
}
// isDatabaseObject checks if an expression represents a database-related object
func isDatabaseObject(expr ast.Expr) bool {
// Check for common database variable names
dbPatterns := []string{"db", "database", "conn", "connection", "client", "session"}
switch e := expr.(type) {
case *ast.Ident:
name := strings.ToLower(e.Name)
for _, pattern := range dbPatterns {
if strings.Contains(name, pattern) {
return true
}
}
case *ast.SelectorExpr:
name := strings.ToLower(e.Sel.Name)
for _, pattern := range dbPatterns {
if strings.Contains(name, pattern) {
return true
}
}
}
return false
}
The go/analysis framework integrates with staticcheck,
golangci-lint, and
go/packages for comprehensive analysis.
Rust: Clippy
Clippy is Rust's official linter, and you can add custom lints using the rustc lint API:
// Clippy custom lint: QUERY_IN_LOOP
//
// Detects database query execution inside loops in Rust code.
// This lint helps prevent N+1 query problems in Rust database applications.
use clippy_utils::diagnostics::span_lint_and_help;
use clippy_utils::is_in_loop;
use rustc_hir::{Expr, ExprKind};
use rustc_lint::{LateContext, LateLintPass};
use rustc_session::{declare_lint, declare_lint_pass};
declare_lint! {
/// ### What it does
/// Checks for database query execution inside loops.
///
/// ### Why is this bad?
/// Executing queries inside loops leads to N+1 query problems where
/// N separate queries are made instead of a single batched query.
/// This causes severe performance degradation.
///
/// ### Example
/// ```rust
/// // Bad: Query inside loop
/// for user_id in user_ids {
/// let orders = sqlx::query("SELECT * FROM orders WHERE user_id = ?")
/// .bind(user_id)
/// .fetch_all(&pool)
/// .await?;
/// }
///
/// // Good: Batched query
/// let orders = sqlx::query("SELECT * FROM orders WHERE user_id IN (?)")
/// .bind(&user_ids)
/// .fetch_all(&pool)
/// .await?;
/// ```
pub QUERY_IN_LOOP,
Warn,
"database queries inside loops"
}
declare_lint_pass!(QueryInLoop => [QUERY_IN_LOOP]);
impl<'tcx> LateLintPass<'tcx> for QueryInLoop {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'_>) {
// Only check method calls
if let ExprKind::MethodCall(path, receiver, args, _) = expr.kind {
let method_name = path.ident.as_str();
// Check if this is a database query method
if is_query_method(method_name) {
// Check if we're inside a loop
if is_in_loop(expr) {
// Check if the receiver is a database-related type
if is_database_type(cx, receiver) {
span_lint_and_help(
cx,
QUERY_IN_LOOP,
expr.span,
&format!(
"database query method '{}' called inside a loop",
method_name
),
None,
"consider batching queries using WHERE IN clauses or collecting IDs first",
);
}
}
}
}
}
}
/// Check if the method name suggests a database query operation
fn is_query_method(name: &str) -> bool {
matches!(
name,
"query"
| "query_as"
| "execute"
| "fetch_one"
| "fetch_all"
| "fetch_optional"
| "prepare"
| "prepare_with"
)
}
/// Check if the receiver type is database-related
fn is_database_type(cx: &LateContext<'_>, expr: &Expr<'_>) -> bool {
let ty = cx.typeck_results().expr_ty(expr);
let ty_str = ty.to_string();
// Check for common database crate types
ty_str.contains("sqlx::")
|| ty_str.contains("diesel::")
|| ty_str.contains("tokio_postgres::")
|| ty_str.contains("rusqlite::")
|| ty_str.contains("sea_orm::")
|| ty_str.contains("Pool")
|| ty_str.contains("Connection")
|| ty_str.contains("Transaction")
}
Clippy lints can be early or late pass, with late pass lints having access to type information from the High-level Intermediate Representation (HIR).
CI/CD Integration
Custom rules are most effective when they run automatically in CI/CD pipelines. Here's a GitHub Actions workflow that runs PHPStan and other language-specific analysers:
name: Static Analysis
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
phpstan:
name: PHPStan Analysis
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: mbstring, xml, ctype, json
coverage: none
- name: Validate composer.json
run: composer validate --strict
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: vendor
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-suggest
- name: Run PHPStan
run: vendor/bin/phpstan analyse --error-format=github --no-progress
- name: Generate PHPStan baseline (on failure)
if: failure()
run: |
echo "PHPStan analysis failed. Generate baseline with:"
echo "vendor/bin/phpstan analyse --generate-baseline"
multi-language-linting:
name: Multi-Language Linting
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# ESLint for JavaScript/TypeScript
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install npm dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
# Pylint for Python
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install Python dependencies
run: pip install pylint
- name: Run Pylint
run: pylint src/ --load-plugins=pylintrc.query_in_loop
# Go vet and custom analyzers
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run Go analyzers
run: |
go vet ./...
go run ./tools/queryinloop ./...
# Clippy for Rust
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: clippy
override: true
- name: Run Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features -- -D warnings
security-audit:
name: Security Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: PHP Security Checker
uses: symfonycorp/security-checker-action@v5
- name: npm audit
run: npm audit --audit-level=moderate
- name: Go vulnerability check
uses: golang/govulncheck-action@v1
This workflow runs multiple static analysis tools in parallel, including PHPStan, ESLint, Pylint, Go analysers, and Clippy.
The --error-format=github
flag makes PHPStan errors appear as
annotations
in pull requests.
Best Practices
Start Small and Focused
Don't try to enforce everything at once. Start with one high-value rule (like queries in loops) and expand from there. Each rule should address a specific, well-defined problem.
Make Error Messages Educational
Your error messages are documentation. They should teach developers why the rule exists and how to fix violations. Include links to internal documentation, relevant PSR standards, or external resources.
Use Baselines for Gradual Adoption
PHPStan baselines let you introduce
strict rules without requiring immediate fixes to existing violations. Generate a baseline with
vendor/bin/phpstan analyse --generate-baseline, then prevent new violations while gradually fixing old ones.
Test Your Rules Thoroughly
Use RuleTestCase
to verify your rules work correctly. Include edge cases, false positives, and complex scenarios in your test suite.
Version Your Rule Identifiers
Use consistent, namespaced identifiers for your rules (like app.queryInLoop). This makes it easy to
ignore specific errors
when necessary and track which rules are causing issues.
Document Your Rules
Maintain internal documentation that explains each custom rule: what it checks, why it exists, and how to fix violations. This is especially important for onboarding new team members.
Advanced Techniques
Using Collectors for Whole-Codebase Analysis
Some rules need to analyse the entire codebase, not just individual nodes. PHPStan collectors gather data across multiple files, enabling rules like unused code detection or cross-file dependency analysis.
Virtual Nodes for Special Contexts
PHPStan provides virtual nodes for contexts that regular AST nodes don't cover:
FileNode- File-level analysisInClassNode- Class-level contextInClassMethodNode- Method-level context with reflectionClassPropertyNode- Handles both traditional and promoted properties
Leveraging PHPStan Extensions
PHPStan has a rich ecosystem of extensions that enhance analysis:
- phpstan-phpunit - Enhanced PHPUnit analysis
- phpstan-doctrine - Doctrine ORM type inference
- phpstan-symfony - Symfony framework support
- phpstan-strict-rules - Additional strict checks
- phpstan-deprecation-rules - Detect deprecated code usage
Real-World Impact
Custom PHPStan rules provide measurable benefits:
Preventing Regressions
Once you've fixed a class of bugs (like N+1 queries), custom rules prevent them from reappearing. The fix is encoded in a rule that runs on every commit.
Scaling Code Review
Reviewers can focus on business logic and architecture instead of catching style violations or common mistakes. The static analyser does the tedious work.
Onboarding Developers
Educational error messages teach new developers your project's conventions as they code. The feedback is immediate and contextual, not delayed until code review.
Enforcing Architecture
Architectural decisions (like "no business logic in destructors" or "always use dependency injection") become automatically enforced rather than relying on documentation that developers might miss.
Reducing CI/CD Costs
Static analysis is cheap - it costs pennies in compute time. Compare this to the cost of running extensive test suites or, worse, discovering bugs in production. Rules catch issues in seconds, not minutes or hours.
Complementing LLMs
Static analysis tools like PHPStan don't replace LLMs - they complement them:
- LLMs excel at: Generating code, explaining complex patterns, suggesting refactorings, understanding natural language requirements
- Static analysis excels at: Comprehensive codebase scanning, deterministic rule enforcement, fast execution, integration testing
The ideal workflow combines both: use LLMs like Claude Sonnet 4.5 or GPT-4.1 to generate code and explore solutions, then use PHPStan to verify that the generated code follows your project's standards. The LLM generates, the static analyser validates.
For codebases too large to fit in context windows, you can use static analysis to identify problem areas (like files with high cyclomatic complexity or modules with many dependencies), then feed those specific areas to an LLM for refactoring suggestions.
Conclusion
Custom PHPStan rules transform your project's conventions from documentation into executable, automatically enforced standards. They're deterministic, comprehensive, and cheap - qualities that complement (rather than replace) AI-powered development tools.
By writing rules that detect performance problems, enforce architectural decisions, ensure test quality, and eliminate common mistakes, you create a feedback loop that makes your entire team more productive. The rules catch issues in seconds, provide educational guidance, and prevent regressions.
Start small: pick one high-value rule (like detecting queries in loops), implement it, measure the impact, and expand from there. Your codebase will thank you.
Resources
PHPStan Documentation
- PHPStan Official Website
- Writing Custom Rules
- Testing Rules
- Configuration Reference
- Using Baselines
- PHPStan GitHub Repository
- PHPStan on Packagist
PHP-Parser (AST Library)
Other Language Static Analysis Tools
- ESLint - Custom Rules Guide
- typescript-eslint - Custom Rules
- Pylint - Custom Checkers
- go/analysis - Go Static Analysis Framework
- Clippy - Adding Custom Lints
Related Standards and Concepts
- PHP-FIG (PSR Standards)
- PSR-11: Container Interface
- Abstract Syntax Trees (Wikipedia)
- Dependency Injection (Wikipedia)
- N+1 Query Problem