mspirkov/yii2-phpstan-rules A set of PHPStan rules for projects using the Yii2 framework

993323

Yii2 PHPStan rules

  1. What's inside
  2. Installation
  3. Configuration
  4. The rules

A set of PHPStan rules for Yii2 projects that I put together for my own day-to-day work. They check for a handful of things I personally try to avoid — business logic piling up in controllers, database access in views, Yii::$app being read and written from anywhere, model rules() arrays that look fine but aren't. In my experience they help keep a Yii2 codebase a bit cleaner and more maintainable, but they're just my opinions turned into checks, not a universal standard — use what's useful, ignore or disable the rest.

PHP Yii 2.0.x Tests PHPStan Coverage PHPStan Level Max

What's inside

Rule Catches
noComplexControllerActions Controller actions with too much branching/looping — logic that belongs in a service
noComplexActionClasses The same, for standalone yii\base\Action classes
noControllerActionCallsViaThis $this->actionFoo() inside a controller instead of a redirect or shared method
noDbQueriesInControllers Direct DB/ActiveRecord access in controllers
noDbQueriesInActions Direct DB/ActiveRecord access in Action classes
noDbQueriesInViews Direct DB/ActiveRecord access in view files
noDynamicQueryWhere String-concatenated conditions passed to Query::where() / andWhere()
noForbiddenYiiAppProperties Reads of arbitrary Yii::$app->* components
noYiiAppPropertyMutation Writes to Yii::$app properties, including setComponents()
noDirectSuperglobals Direct use of $_GET, $_POST, $_SESSION, etc.
modelRulesValidation Malformed or invalid rules() in yii\base\Model — unknown validators, missing required options, bad regexes, and more

Every rule ships with its own PHPStan error identifier (mspirkovYii2Rules.*), so you can target ignoreErrors precisely instead of silencing a whole rule.

Installation

php composer.phar require --dev mspirkov/yii2-phpstan-rules

If your project uses phpstan/extension-installer, the rules are picked up automatically — nothing else to do.

Otherwise, include them manually in your phpstan.neon:

includes:
    - vendor/mspirkov/yii2-phpstan-rules/rules.neon

Configuration

All rules are on by default. Turn the whole set off, or tune individual rules, under parameters.mspirkovYii2Rules:

parameters:
    mspirkovYii2Rules:
        enableAllRules: true

        # Component IDs treated as "the database" by the DB-access rules
        yiiAppDbProperties:
            - db

        # Thresholds for the complexity rules — exceeding any one flags the method
        actionComplexity:
            ifCount: 3
            foreachCount: 0
            forCount: 0
            whileCount: 0
            doWhileCount: 0
            switchCount: 0
            matchCount: 0
            ternaryCount: 1
            tryCatchCount: 1

        # Yii::$app properties allowed to be read anywhere (e.g. request-agnostic settings)
        noForbiddenYiiAppProperties:
            allowedProperties:
                - id
                - name
                - charset
                - language
                - timeZone

        # Disable a single rule without touching the rest
        noDynamicQueryWhere:
            enabled: false

The rules

Complexity limits

noComplexControllerActions and noComplexActionClasses count if, foreach, for, while, do-while, switch, match, ternaries, and try/catch blocks inside a controller action or Action::run(). Cross any configured threshold and the rule fires, pointing at the exact construct that pushed it over:

// ✗ flagged: 4 `if` statements against a default limit of 3
public function actionCheckout()
{
    if ($cart->isEmpty()) { /* ... */ }
    if (!$user->hasPaymentMethod()) { /* ... */ }
    if ($stock->isLow($cart)) { /* ... */ }
    if ($coupon->isExpired()) { /* ... */ }
}

// ✓ the decision tree moves to a service, the action just orchestrates
public function actionCheckout()
{
    $this->checkoutService->process($cart, $user);
}
No calling actions via $this
// ✗ flagged: bypasses the action-resolution pipeline (filters, events, results)
public function actionEdit($id)
{
    // ...
    return $this->actionView($id);
}

// ✓ redirect, or extract the shared part into a private method / service
public function actionEdit($id)
{
    return $this->redirect(['view', 'id' => $id]);
}
No database access outside repositories

Fires on ActiveRecord::find()/findOne()/save(), Yii::$app->db->createCommand(), Query::all()/one()/count(), transactions, and friends — wherever they turn up in a controller, an Action, or a view file.

// ✗ flagged in a view
<?php foreach (Post::find()->where(['status' => 1])->all() as $post): ?>

// ✓ the controller/action fetches the data, the view only renders it
<?php foreach ($posts as $post): ?>

noDbQueriesInControllers / noDbQueriesInActions push the same query building into a repository or service instead.

No dynamic SQL strings
// ✗ flagged: string-built condition, one step from SQL injection
$query->where("status = $status");
$query->where('status = ' . $status);

// ✓ array condition syntax — parameterized, and PHPStan can see the shape
$query->where(['status' => $status]);
Taming Yii::$app

Two rules keep the service locator from becoming a place where any property can be read or reassigned from anywhere:

// ✗ noForbiddenYiiAppProperties: arbitrary component access
$cache = Yii::$app->cache;

// ✗ noYiiAppPropertyMutation: mutating the container at runtime
Yii::$app->params = [];
Yii::$app->setComponents([...]);

// ✓ inject the component instead
public function __construct(private CacheInterface $cache) {}

A short allowlist (id, name, charset, language, timeZone by default) stays available everywhere since those are effectively static configuration, not injectable services.

No raw superglobals
// ✗ flagged, with the fix suggested in the error message
$id = $_GET['id'];

// ✓
$id = Yii::$app->request->getQueryParam('id');

Covers $_GET, $_POST, $_REQUEST, $_SESSION, $_COOKIE, $_FILES, and $_SERVER, each pointing at the matching yii\web\Request / Session / UploadedFile API.

Model validation rules that lie

Model::rules() is just a plain array — PHP will never tell you that you forgot a validator's required option, wrote an invalid regex, or misconfigured one of its options. For every rule entry the validator type resolves to (a built-in alias like required/string/number/compare/date/match/in/unique/exist/file/image/ip/url, a custom Validator subclass, or an inline closure/method), this rule statically checks the option array against what that validator actually accepts and requires. A validator name it can't resolve at all — a typo, or an alias registered elsewhere — is left alone rather than guessed at:

public function rules()
{
    return [
        ['email', 'exist', 'targetClass' => X::class],   // ✗ 'exist' requires 'targetAttribute'
        ['code', 'match', 'pattern' => '/[/'],           // ✗ invalid regular expression
        ['ip', 'ip', 'ipv4' => false, 'ipv6' => false],  // ✗ disables both protocols
        ['message', 'string', 'max' => 'invalid'],       // ✗ 'max' must be int|null
        ['status', 'someUnregisteredAlias'],             // — unresolved name, not checked

        ['name', 'string', 'max' => 255],                // ✓
    ];
}
0 0
1 follower
0 downloads
Yii Version: 2.0
License: MIT
Category: Others
Tags: PHPStan, yii2
Created on: Jul 4, 2026
Last updated: (not set)
Packagist Profile
Github Repository

Related Extensions