Yii2 PHPStan 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.
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], // ✓
];
}
If you have any questions, please ask in the forum instead.
Signup or Login in order to comment.