mspirkov/yii2-phpstan-rules

A set of PHPStan rules for projects using the Yii2 framework

Maintainers

Package info

github.com/mspirkov/yii2-phpstan-rules

Type:phpstan-extension

pkg:composer/mspirkov/yii2-phpstan-rules

Transparency log

Statistics

Installs: 418

Dependents: 2

Suggesters: 0

Stars: 19

Open Issues: 0

0.11.0 2026-08-03 06:45 UTC

This package is auto-updated.

Last update: 2026-08-03 06:52:57 UTC


README

Yii2 PHPStan rules

A set of PHPStan rules for Yii2 projects that I put together for my own day-to-day work. Yii2 leans heavily on loosely-typed config arrays and magic properties/methods that PHPStan can't see through on its own, and on conventions — like keeping business logic and database access out of controllers and views — that are easy to drift from without anyone noticing. These rules catch both: they validate Yii2-specific config and structure statically, and they enforce the architectural boundaries and other code-quality checks I try to keep in a codebase. 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 Yii2 PHPStan Tests Coverage PHPStan Level Max

Support

If you like this project, give it a ⭐ on GitHub — it helps others discover it.

Installation

Important

It works better with the latest versions of PHP, Yii2, and PHPStan. The more up-to-date the versions are, the more accurate the analysis is.

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, turn off just one of the two rule groups, or tune individual rules, under parameters.mspirkovYii2Rules:

parameters:
    mspirkovYii2Rules:
        # Master switch — false disables every rule below
        enableAllRules: false

        # Covers just the `*Validation` rules (config/shape checks like modelRulesValidation,
        # componentBehaviorsValidation, activeQueryWithValidation, ...) — defaults to
        # enableAllRules, so setting it only makes sense when it should differ. Here it keeps
        # static config validation on while the `no*` code-quality rules stay off.
        enableValidationRules: true

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

        # Classes to skip in config-array validation (unknown option / wrong option type
        # checks) — shared by every rule that validates a class against a config array:
        # baseObjectInstantiationValidation, yiiCreateObjectValidation,
        # componentBehaviorsValidation, controllerActionsValidation,
        # widgetPropertiesValidation, and modelRulesValidation. Useful when a class's
        # constructor consumes some config keys itself instead of leaving them for
        # Yii::configure() to apply to a public, checkable property.
        # Omit "attributes" to skip the class entirely; list them to skip only those options.
        baseObjectConfigValidation:
            skippedClasses:
                -
                    class: app\payment\PaymentGateway
                    attributes:
                        - retryPolicy
                -
                    class: app\sdk\ThirdPartySdkClient

        # 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 application properties allowed to be read anywhere (e.g. request-agnostic settings)
        noForbiddenYiiAppProperties:
            allowedProperties:
                - id
                - name
                - charset
                - language
                - timeZone

        # Project-specific model validator aliases
        modelRulesValidation:
            customValidators:
                slug: app\validators\SlugValidator

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

Rules at a glance

Validation rules

Statically validate Yii2's loosely-typed config arrays and array-driven conventions — shapes PHPStan can't check on its own because they only take effect at runtime. Toggle all of them at once with enableValidationRules.

Rule Catches
activeFormFieldValidation ActiveForm::field() calls targeting an attribute that is missing, read-only, or write-only on the given model
activeQueryWithValidation with() / joinWith() / innerJoinWith() calls referencing a relation that doesn't exist on the queried ActiveRecord model
activeRecordConditionValidation findOne() / findAll() / deleteAll() / updateAll() / updateAllCounters() WHERE conditions with an unknown attribute or a mismatched value type
activeRecordRelationValidation Invalid hasOne() / hasMany() link properties that do not exist on the current or related ActiveRecord model
activeRecordUpdateValuesValidation updateAll() / updateAllCounters() attribute or counter values with an unknown attribute or a mismatched value type
baseObjectInstantiationValidation new on a yii\base\BaseObject subclass whose last constructor argument is a $config array, with bad config keys and bad option types
componentBehaviorsValidation Malformed or invalid behaviors() in yii\base\Component — unknown behavior classes, bad config keys, and bad option types
controllerActionsValidation Malformed or invalid actions() in yii\base\Controller — unknown action classes, bad config keys, and bad option types
htmlActiveAttributeValidation Html::activeInput() / activeTextInput() / etc. calls referencing an attribute that does not exist on the given model
modelAttributeHintsValidation attributeHints() entries in yii\base\Model that target attributes that don't exist, or use an empty attribute name
modelAttributeLabelsValidation attributeLabels() entries in yii\base\Model that target attributes that don't exist, or use an empty attribute name
modelRulesValidation Malformed or invalid rules() in yii\base\Model — unknown validators, missing required options, bad regexes, unknown attributes, and more
modelScenariosValidation scenarios() entries in yii\base\Model with an empty name, a non-array attribute list, or an unknown attribute
queryConditionValidation where() / andWhere() / orWhere() operator-format conditions (in, between, like, etc.) with the wrong number of operands
uploadedFileInstanceValidation UploadedFile::getInstance() / getInstances() calls referencing an attribute that does not exist on the given model
widgetPropertiesValidation Unknown or mistyped option keys and bad option types in Widget::begin() / Widget::widget() config arrays
yiiCreateObjectValidation Yii::createObject() config arrays missing class/__class, bad config keys, and bad option types

Code quality rules

Catch architectural drift, complexity, and other code-quality issues that are easy to miss without anyone noticing — business logic and database access staying out of controllers and views, actions calling other actions directly, superglobals, dynamic SQL, an Application object that anything can read from or write to, and calls that are provably redundant.

Rule Catches
noComplexActionClasses Standalone yii\base\Action classes with too much branching/looping — logic that belongs in a service
noComplexControllerActions The same, for controller actions
noControllerActionCallsViaThis $this->actionFoo() inside a controller instead of a redirect or shared method
noDbQueriesInActions Direct DB/ActiveRecord access in Action classes
noDbQueriesInControllers Direct DB/ActiveRecord access in controllers
noDbQueriesInViews Direct DB/ActiveRecord access in view files
noDirectSuperglobals Direct use of $_GET, $_POST, $_SESSION, etc.
noDynamicQueryWhere String-concatenated conditions passed to Query::where() / andWhere()
noForbiddenYiiAppProperties Reads of arbitrary yii\base\Application components, including Yii::$app->*
noRedundantExistenceCheck Query::one() !== null / Query::count() compared against 0 or 1 where Query::exists() suffices
noRedundantHtmlEncode Html::encode() calls whose argument is always a numeric-string
noYiiAppPropertyMutation Writes to yii\base\Application properties, including setComponents()

Rule reference

Validation rules

Active Form field validation

ActiveForm::field($model, $attribute) binds an editable input to the attribute: it reads the current value to render the input, and writes the submitted value back to the model on load(). This rule checks that the attribute is both readable and writable — a declared (non-readonly) property, a PHPDoc @property, or a matching getter/setter pair — and reports it whether it's missing entirely or only exists as read-only or write-only. yii\base\DynamicModel instances (and subclasses) are skipped entirely, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically.

/**
 * @property string $email
 * @property-read string $fullName
 */
final class ContactModel extends Model
{
    public $name;

    public function getPhone(): string { /* ... */ }

    public function setPhone(string $phone): void { /* ... */ }
}
/** @var ContactModel $model */

$form = ActiveForm::begin();

echo $form->field($model, 'name');     // ✓ declared property
echo $form->field($model, 'email');    // ✓ declared via @property
echo $form->field($model, 'phone');    // ✓ has both getPhone() and setPhone()
echo $form->field($model, 'fullName'); // ✗ read-only — declared via @property-read, nothing to write the submitted value back to
echo $form->field($model, 'nickname'); // ✗ typo — "nickname" is not a property on ContactModel

ActiveForm::end();

ActiveQuery with() validation

with(), joinWith(), and innerJoinWith() take relation names as plain strings, so a typo (or a relation that got renamed) silently returns no related data instead of failing. This rule checks that every relation name passed to these methods — including a joinWith()/innerJoinWith() alias ('orders o' or 'orders AS o') and a dotted sub-relation path ('orders.items') — resolves to an actual relation (a getXxx() method returning something compatible with yii\db\ActiveQueryInterface) on the queried model.

Validating a sub-relation requires knowing which model the parent relation points to. This rule can work that out two ways: from the relation getter's own @return ActiveQuery<T> PHPDoc, or from a @property-read T / @property-read T[] PHPDoc property of the same name on the model (the same resolution activeFormFieldValidation and friends already rely on). A relation whose target model can't be determined either way is still checked for existence at its own level, but any further sub-relation path past it is left unchecked rather than guessed at.

/**
 * @property-read Address $address
 */
class Customer extends ActiveRecord
{
    /** @return ActiveQuery<Order> */
    public function getOrders()
    {
        return $this->hasMany(Order::class, ['customer_id' => 'id']);
    }

    public function getAddress()
    {
        return $this->hasOne(Address::class, ['id' => 'address_id']);
    }
}

class Order extends ActiveRecord
{
    /** @return ActiveQuery<Item> */
    public function getItems()
    {
        return $this->hasMany(Item::class, ['order_id' => 'id']);
    }
}

class Address extends ActiveRecord
{
    /** @return ActiveQuery<Country> */
    public function getCountry()
    {
        return $this->hasOne(Country::class, ['id' => 'country_id']);
    }
}

class Item extends ActiveRecord { /* ... */ }
class Country extends ActiveRecord  { /* ... */ }
Customer::find()->with('orders')->all();           // ✓
Customer::find()->with('orders.items')->all();     // ✓ Order declares its own "items" relation
Customer::find()->with('address.country')->all();  // ✓ related model resolved via @property-read
Customer::find()->joinWith('orders o')->all();     // ✓ alias is stripped before the relation is checked
Customer::find()->with('oders')->all();             // ✗ typo — no such relation on Customer
Customer::find()->with('orders.oops')->all();       // ✗ typo — no such relation on Order

Active Record condition validation

findOne(), findAll(), and deleteAll() take a plain array condition (['attribute' => value], with an array value matched as an IN (...) condition) — and so does the second, condition argument of updateAll() / updateAllCounters(). Like attributeLabels() and scenarios(), this is never checked against the model until the query actually runs. This rule checks that every attribute name in a condition array exists on the queried ActiveRecord model (the same @property-aware resolution as activeRecordRelationValidation) and that its value's type is compatible with the attribute's declared type. Only array literals with a resolvable string key are checked; primary-key-only lookups (findOne(1), findOne([1, 2])) and dynamically-built condition arrays are left alone. A value implementing yii\db\ExpressionInterface (e.g. new Expression('NOW()')) is accepted for any attribute regardless of its declared type — yii\db\conditions\HashConditionBuilder builds it as raw SQL instead of type-casting it, and does so per-value inside an IN (...) array too.

/**
 * @property int $id
 * @property int $status
 * @property string $updated_at
 */
final class Customer extends ActiveRecord { /* ... */ }

Customer::findOne(1);                                         // ✓ primary key lookup, not a condition hash
Customer::findOne(['status' => 1]);                           // ✓
Customer::findOne(['status' => [1, 2]]);                      // ✓ IN (1, 2)
Customer::findOne(['updated_at' => new Expression('NOW()')]); // ✓ raw SQL, not type-checked
Customer::findOne(['statuss' => 1]);                          // ✗ typo — unknown attribute
Customer::findOne(['status' => '1']);                         // ✗ wrong type — int expected
Customer::deleteAll(['statuss' => 1]);                        // ✗ typo — unknown attribute
Customer::updateAll(['status' => 1], ['idd' => 5]);           // ✗ typo — unknown attribute in the condition

Active Record relations validation

hasOne() and hasMany() relation links are plain string arrays: the array keys belong to the related AR class, and the values belong to the current AR class. This rule checks that those properties exist, including properties declared through PHPDoc @property.

/**
 * @property int $id
 * @property int $customer_id
 * @property int $shipping_address_id
 */
final class Order extends ActiveRecord
{
    public function getShippingAddress(): ActiveQuery
    {
        // ✗ missing property "uuid" on Address
        return $this->hasOne(Address::class, ['uuid' => 'shipping_address_id']);
    }

    public function getItems(): ActiveQuery
    {
        // ✗ missing property "order_uuid" on Order
        return $this->hasMany(OrderItem::class, ['order_id' => 'order_uuid']);
    }

    public function getCustomer(): ActiveQuery
    {
        // ✓
        return $this->hasOne(Customer::class, ['id' => 'customer_id']);
    }
}

/**
 * @property int $id
 */
final class Customer extends ActiveRecord { /* ... */ }

/**
 * @property int $id
 */
final class Address extends ActiveRecord { /* ... */ }

/**
 * @property int $id
 * @property int $order_id
 */
final class OrderItem extends ActiveRecord { /* ... */ }

Active Record update values validation

updateAll()'s attribute values and updateAllCounters()'s counter values are the other plain array these two methods take — the values written into the row, as opposed to the WHERE condition activeRecordConditionValidation checks. This rule checks that every attribute name exists on the ActiveRecord model and that its value's type is compatible with the attribute's declared type; unlike a condition, these values are written as-is, so (unlike activeRecordConditionValidation) an array value is not treated as an IN (...) shorthand and is always a type mismatch. As with a condition, a value implementing yii\db\ExpressionInterface is accepted for any attribute regardless of its declared type — yii\db\QueryBuilder::prepareUpdateSets() builds it as raw SQL instead of type-casting it.

/**
 * @property int $id
 * @property int $status
 * @property int $age
 * @property string $updated_at
 */
final class Customer extends ActiveRecord { /* ... */ }

Customer::updateAll(['status' => 1], ['id' => 5]);              // ✓
Customer::updateAll(['updated_at' => new Expression('NOW()')]); // ✓ raw SQL, not type-checked
Customer::updateAll(['statuss' => 1]);                          // ✗ typo — unknown attribute
Customer::updateAll(['status' => 'active']);                    // ✗ wrong type — int expected
Customer::updateAllCounters(['age' => 1]);                      // ✓
Customer::updateAllCounters(['agee' => 1]);                     // ✗ typo — unknown attribute

BaseObject instantiation validation

yii\base\BaseObject::__construct($config = []) applies $config via Yii::configure($this, $config), the same mechanism Yii::createObject() uses to apply its own config array — so a typo'd key or wrong-typed value in a plain new SomeObject([...]) call is just as invisible to PHPStan as it is in a createObject() config array. This rule checks a new call the same way yiiCreateObjectValidation checks Yii::createObject(): config keys against the target class's writable properties, and literal values against their declared types. It only looks at classes extending yii\base\BaseObject, and only at a literal array passed as the constructor's last argument when that argument is exactly the one named $config — the Yii2 convention for opting into array-config construction. A subclass whose last parameter isn't named config (or is variadic) doesn't follow that convention, so its last argument is left alone.

$countQuery = Article::find()->where(['status' => 1]);

new Pagination(['totalCount' => $countQuery->count()]);  // ✓
new Pagination(['totalCoutn' => 100]);                   // ✗ typo — unknown option "totalCoutn"
new Pagination(['defaultPageSize' => '20']);             // ✗ wrong type — int expected

Component behaviors validation

Component::behaviors() uses Yii object configs, so typos usually wait until runtime. This rule checks statically visible behavior definitions on yii\base\Component subclasses, including models: classes that do not extend yii\base\Behavior, bad config keys, unknown config options, and option value types inferred from public properties or setters.

public function behaviors(): array
{
    return [
        'timestamp' => [
            'class' => TimestampBehavior::class,
            'createdAtAtribute' => 'created_at',     // ✗ typo — unknown option
        ],
        'typecast' => [
            'class' => AttributeTypecastBehavior::class,
            'attributeTypes' => [
                'views_count' => AttributeTypecastBehavior::TYPE_INTEGER,
                'is_published' => AttributeTypecastBehavior::TYPE_BOOLEAN,
            ],
            'typecastAfterValidate' => 1,            // ✗ bool expected
        ],
        'invalid' => stdClass::class,                // ✗ not a yii\base\Behavior

        'slug' => [
            'class' => SluggableBehavior::class,
            'attribute' => 'title',                  // ✓
        ],
    ];
}

Controller actions validation

Controller::actions() shares the same object-config shape as Component::behaviors() — this rule checks statically visible action definitions on yii\base\Controller subclasses: classes that do not extend yii\base\Action, an empty action ID, bad config keys, unknown config options, and option value types inferred from public properties or setters.

public function actions(): array
{
    return [
        'error' => [
            'class' => ErrorAction::class,
            'vieww' => 'error',            // ✗ typo — unknown option
        ],
        'captcha' => [
            'class' => CaptchaAction::class,
            'fixedVerifyCode' => 1,        // ✗ string expected
        ],
        'invalid' => stdClass::class,      // ✗ not a yii\base\Action

        'download' => [
            'class' => DownloadAction::class,
            'path' => '@app/uploads',      // ✓
        ],
    ];
}

Html active attribute validation

Html::activeInput(), activeTextInput(), and the rest of the active*() family (activeHiddenInput, activePasswordInput, activeFileInput, activeTextarea, activeRadio, activeCheckbox, activeDropDownList, activeListBox, activeCheckboxList, activeRadioList, activeLabel, activeHint) all take a model and a plain attribute-name string, the same as ActiveForm::field(). This rule checks that the attribute exists on the given model, the same @property-aware resolution used by activeFormFieldValidation and uploadedFileInstanceValidation. yii\base\DynamicModel instances are skipped, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically.

/**
 * @property string $email
 */
final class ContactModel extends Model
{
    public $name;
}

/** @var ContactModel $model */

echo Html::activeLabel($model, 'name');    // ✓ declared property
echo Html::activeInput('text', $model, 'email');  // ✓ declared via @property
echo Html::activeTextInput($model, 'nema');       // ✗ typo — "nema" is not a property on ContactModel
echo Html::activeHint($model, 'nickname');        // ✗ typo — "nickname" is not a property on ContactModel

Model attribute hints validation

Model::attributeHints() is just as easy to get wrong as attributeLabels() — a typo'd key silently means the hint is never shown for the intended attribute. This rule checks that every key is an existing property on the model (as a declared property or a PHPDoc @property, same resolution as modelRulesValidation) and isn't left empty — though the existence check alone is skipped for yii\base\DynamicModel subclasses, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically:

/**
 * @property string $email
 */
final class ContactModel extends Model
{
    public $name;

    public function attributeHints(): array
    {
        return [
            'name' => 'Your full name',
            'emial' => 'We will reply here',   // ✗ typo — "emial" is not a property on ContactModel
            'email' => 'We will reply here',   // ✓ declared via @property
        ];
    }
}

Model attribute labels validation

Model::attributeLabels() is just as easy to get wrong as rules() — a typo'd key silently falls back to the default humanized attribute name instead of showing your label. This rule checks that every key is an existing property on the model (as a declared property or a PHPDoc @property, same resolution as modelRulesValidation) and isn't left empty — though the existence check alone is skipped for yii\base\DynamicModel subclasses, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically:

/**
 * @property string $email
 */
final class ContactModel extends Model
{
    public $name;

    public function attributeLabels(): array
    {
        return [
            'name' => 'Name',
            'emial' => 'E-mail',   // ✗ typo — "emial" is not a property on ContactModel
            'email' => 'E-mail',   // ✓ declared via @property
        ];
    }
}

Model validation rules validation

Model::rules() is just a plain array — PHP will never tell you that you forgot a validator's required option, wrote an invalid regex, misconfigured one of its options, or targeted an attribute that doesn't even exist. 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, a configured project alias, 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 is reported as an error; add project-specific aliases under modelRulesValidation.customValidators:

public function rules(): array
{
    return [
        ['email', 'string', 'lenght' => 255],             // ✗ typo — unknown option "lenght" for StringValidator
        ['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'],              // ✗ unknown validator

        ['name', 'string', 'max' => 255],                 // ✓
    ];
}

This rule also checks that the attribute names at index 0 of each rule (including array lists of attributes) actually exist on the model, the same way activeRecordRelationValidation checks relation links — as a declared property or a PHPDoc @property. It only reports on attribute names it can resolve to a literal or constant string; anything built dynamically at runtime is left alone. This check alone is skipped for yii\base\DynamicModel subclasses, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically.

/**
 * @property string $email
 */
final class ContactModel extends Model
{
    public $name;

    public function rules(): array
    {
        return [
            ['name', 'required'],
            ['emial', 'required'],   // ✗ typo — "emial" is not a property on ContactModel
            ['email', 'string'],     // ✓ declared via @property
        ];
    }
}

Model scenarios validation

Model::scenarios() maps scenario names to the attributes active in them, and PHP won't tell you that a scenario name is empty, an attribute list isn't actually an array, or an attribute doesn't exist on the model — the same way modelAttributeLabelsValidation checks attributeLabels(). An attribute prefixed with ! (Yii's "unsafe" marker) is checked under its unprefixed name. The attribute-existence check alone is skipped for yii\base\DynamicModel subclasses, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically.

final class ContactModel extends Model
{
    public $name;
    public $email;

    public function scenarios(): array
    {
        return [
            'create' => ['name', 'email'],
            'update' => ['name', '!emial'],  // ✗ typo — "emial" is not a property on ContactModel
            '' => ['name'],                  // ✗ empty scenario name
            'delete' => 'name',              // ✗ must be an array of attribute names
        ];
    }
}

Query condition validation

Query::where() / andWhere() / orWhere() accept an "operator format" array ([operator, operand1, operand2, ...]), and Yii only discovers a missing operand at query-build time — each yii\db\conditions\*Condition::fromArrayDefinition() throws an InvalidArgumentException if its required operands aren't present. This rule checks the operand count against those same rules: not, between / not between, in / not in, like and its variants, and exists / not exists each need a specific minimum (or, for not, an exact) number of operands, and the standard comparison operators (=, !=, <>, >, >=, <, <=) need exactly 2, Yii's documented "arbitrary operator" case. and / or operands are recursed into, since they typically wrap further operator-format sub-conditions; yii\db\conditions\ConjunctionCondition itself never validates their count, but a zero-operand and/or can never produce a meaningful condition, so this rule still requires at least one. Any other operator string — a genuinely custom one registered via QueryBuilder::setConditionClasses() — is left unchecked rather than guessed at, and so is anything built dynamically or in hash format (['status' => 1], never operator-format to begin with).

$query->where(['in', 'status']);                                    // ✗ missing the values operand — needs 2
$query->andWhere(['between', 'age', 18]);                           // ✗ missing the upper bound — needs 3
$query->orWhere(['not', ['in', 'status']]);                         // ✗ same as above, nested inside "not"
$query->where(['>=', 'age', 18, 30]);                               // ✗ arbitrary operator, extra operand — needs exactly 2
$query->where(['and']);                                             // ✗ empty "and" — needs at least 1 operand

$query->where(['in', 'status', [1, 2]]);                            // ✓
$query->andWhere(['between', 'age', 18, 65]);                       // ✓
$query->orWhere(['and', ['status' => 1], ['in', 'type', [1, 2]]]);  // ✓

UploadedFile instance validation

UploadedFile::getInstance($model, $attribute) and getInstances($model, $attribute) build the file input's name from $model and a plain attribute-name string, the same way ActiveForm::field() does — so a typo silently returns null (or an empty array) instead of the uploaded file. This rule checks that the attribute exists on the given model, the same @property-aware resolution used elsewhere (e.g. activeFormFieldValidation, modelAttributeLabelsValidation). yii\base\DynamicModel instances are skipped, since their attributes are defined at runtime via defineAttribute() and can't be resolved statically.

final class UploadForm extends Model
{
    public $imageFile;
    public $imageFiles;

    public function rules(): array
    {
        return [[['imageFile', 'imageFiles'], 'file']];
    }
}

/** @var UploadForm $model */

$model->imageFile = UploadedFile::getInstance($model, 'imageFile');    // ✓
$model->imageFiles = UploadedFile::getInstances($model, 'imageFiles'); // ✓
$model->imageFile = UploadedFile::getInstance($model, 'imagefile');    // ✗ typo — "imagefile" is not a property on UploadForm
$files = UploadedFile::getInstances($model, 'imagefiles');             // ✗ typo — "imagefiles" is not a property on UploadForm

Widget properties validation

Widget::begin($config) / Widget::widget($config) configs are just arrays, like behaviors(), so a typo'd key or a wrong-typed value only fails once the widget renders. This rule checks config keys against the called widget's writable properties and literal values against their declared types.

ActiveForm::begin([
    'method' => 'get',             // ✓ declared property
    'metod' => 'get',              // ✗ typo — unknown option "metod"
    'encodeErrorSummary' => 'yes', // ✗ wrong type — bool expected, string given
]);

ActiveForm::end();

Yii::createObject() validation

Yii::createObject()'s class / __class config array is declared as an open, all-optional PHPStan array shape (array{class?: class-string<T>, __class?: class-string<T>, ...}), so PHPStan itself already flags an unknown or wrong-typed class value — but it stays silent about a missing class/__class key entirely (just an unhelpful "unable to resolve the template type" note) and about every other key in the array, since ... accepts anything. This rule fills exactly those two gaps on calls to createObject() on Yii (or any class extending yii\BaseYii): a clear "must specify class or __class" message, plus config keys checked against the resolved class's writable properties and value types, the same way componentBehaviorsValidation checks behaviors. Callables (a Closure, or a [$target, 'method'] array) are left alone, since they are not object configs.

Yii::createObject([
    'traceLevel' => 3,        // ✗ missing "class" or "__class"
]);

Yii::createObject([
    'class' => Logger::class,
    'traceLevel' => '3',      // ✗ wrong type — int expected
    'flushInteval' => 1000,   // ✗ typo — unknown option (should be "flushInterval")
]);

Yii::createObject([
    'class' => Logger::class,
    'traceLevel' => 3,        // ✓
]);

Code quality rules

Complexity limits

noComplexActionClasses and noComplexControllerActions 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(): string
{
    if ($this->cart->isEmpty()) { /* ... */ }
    if (!$this->cart->hasPaymentMethod()) { /* ... */ }
    if ($this->cart->hasOutOfStockItems()) { /* ... */ }
    if ($this->cart->hasExpiredCoupon()) { /* ... */ }

    return $this->render('checkout', ['cart' => $this->cart]);
}

// ✓ the decision tree moves to a service, the action just orchestrates
public function actionCheckout(): string
{
    return $this->render('checkout', $this->checkoutService->process($this->cart));
}

No calling actions via $this

// ✗ flagged: bypasses the action-resolution pipeline (filters, events, results)
public function actionEdit(int $id): Response
{
    return $this->actionView($id);
}

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

No database access outside repositories

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

// ✗ flagged in a view: queries the database instead of just rendering data
<?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): ?>

noDbQueriesInActions / noDbQueriesInControllers push the same query building into a repository or service instead. Query builder setup counts too: new Query(), $query->where(), and dynamic calls on a Query object are all treated as direct database access in these layers.

No raw superglobals

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

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

// ✓ read through the injected yii\web\Request instead
$id = $this->request->get('id');

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]);

No forbidden Yii::$app properties

Checks any expression typed as yii\base\Application, not just Yii::$app directly. A short allowlist (id, name, charset, language, timeZone by default) stays available everywhere since those are effectively static configuration, not injectable services.

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

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

No redundant existence check

Query::exists() runs a lighter SELECT EXISTS(...) query instead of fetching a row (one()) or counting every matching row (count()). This rule catches the common ways a record-existence check like this ends up written as one of those instead, on any expression typed as yii\db\QueryInterface / yii\db\ActiveQueryInterface — the comparison can be written with the query call on either side, and count() > 0 / count() !== 0 (or, negated, count() < 1 / count() === 0) are flagged the same way as one() !== null / one() === null:

// ✗ flagged: fetches a full row just to test whether one is there
public function emailIsTaken(string $email): bool
{
    return User::find()->where(['email' => $email])->one() !== null;
}

// ✗ flagged: counts every matching row just to test whether one is there
public function emailIsAvailable(string $email): bool
{
    return User::find()->where(['email' => $email])->count() < 1;
}

// ✓ exists() only asks the database whether a row is there
public function emailIsTaken(string $email): bool
{
    return User::find()->where(['email' => $email])->exists();
}

// ✓ same, negated
public function emailIsAvailable(string $email): bool
{
    return !User::find()->where(['email' => $email])->exists();
}

No redundant Html::encode()

PHPStan already flags most nonsensical Html::encode() calls on its own (wrong argument types and the like). The one gap it doesn't cover is a numeric-string argument: a value PHPStan can already prove only ever holds digits, so escaping it can't do anything — htmlspecialchars() never touches a plain number. This rule fires only in that narrow case, on yii\helpers\Html / BaseHtml and their subclasses:

/**
 * @var numeric-string $id
 * @var string $name
 */

echo Html::encode($id);   // ✗ flagged — $id can only ever be a numeric-string
echo Html::encode($name); // ✓ a plain string may still contain special characters

No Yii::$app property mutation

Checks the same yii\base\Application-typed expressions as noForbiddenYiiAppProperties, on the write side.

// ✗ mutation of properties
Yii::$app->params = [...];
Yii::$app->setComponents([...]);