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:

  1. getNodeType() - Returns the AST node type to monitor
  2. processNode() - 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:

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:

Leveraging PHPStan Extensions

PHPStan has a rich ecosystem of extensions that enhance analysis:

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

PHP-Parser (AST Library)

Other Language Static Analysis Tools

Related Standards and Concepts

GitHub Actions and CI/CD