mspirkov / yii2-rector
A set of Rector rules for projects using the Yii2 framework
Package info
github.com/mspirkov/yii2-rector
Type:rector-extension
pkg:composer/mspirkov/yii2-rector
Requires
- php: >=7.4
- rector/rector: ^2.6
- yiisoft/yii2: ^2.0.53
Requires (Dev)
- ergebnis/phpstan-rules: ^2.13
- friendsofphp/php-cs-fixer: ^3.89
- kubawerlos/php-cs-fixer-custom-fixers: ^3.37
- phpstan/phpstan: ^2.2
- phpstan/phpstan-phpunit: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^9.6
- rector/rector: ^2.2
- shipmonk/phpstan-rules: ^4.2
- spaze/phpstan-disallowed-calls: ^4.7
Suggests
- mspirkov/yii2-phpstan-rules: for better error detection in Yii2 projects
README
Yii2 Rector
A set of Rector rules for Yii2 projects that I put together for my own day-to-day work.
They make refactoring a Yii2 codebase easier and help keep it cleaner, automating the framework-specific patterns
— magic properties, ActiveRecord/Query calls, accumulated deprecations — that a generic Rector set has no way
to know about.
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 Rector. The more up‑to‑date the versions, the better the refactoring.
composer require --dev mspirkov/yii2-rector
Usage
use MSpirkov\Yii2\Rector\Yii2SetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths(...) ->withSets([ Yii2SetList::MAIN, ]);
Enabling individual rules
Individual rules can be enabled on their own via ->withRules([...]) instead of ->withSets([...]):
use MSpirkov\Yii2\Rector\Rules\ReplaceClassnameWithClassRector; use MSpirkov\Yii2\Rector\Rules\ReplaceExistenceCheckWithExistsRector; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths(...) ->withRules([ ReplaceClassnameWithClassRector::class, ReplaceExistenceCheckWithExistsRector::class, ]);
Skipping rules
Any rule — whether pulled in through Yii2SetList::MAIN or added individually — can be turned off entirely via
->withSkip([...]):
use MSpirkov\Yii2\Rector\Rules\MergeModelRulesRector; use MSpirkov\Yii2\Rector\Yii2SetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths(...) ->withSets([ Yii2SetList::MAIN, ]) ->withSkip([ MergeModelRulesRector::class, ]);
Mapping a rule to a list of paths/patterns instead skips it only there, leaving it active everywhere else — handy for legacy code that isn't ready for a particular rule yet:
->withSkip([ MergeModelRulesRector::class => [ __DIR__ . '/src/Legacy/*', ], ]);
A plain path/pattern (no rule class key) skips those files from every rule, Yii2-specific or not.
Configuring a rule
AddPropertyTagsRector and RemoveRedundantPropertyTagsRector accept a skippedClasses option — see the
rule reference below for the exact shape of each — and AddPropertyTagsRector additionally
accepts insertBeforeTags. Configure them via ->withConfiguredRule(), using the rule's own constants as keys:
use MSpirkov\Yii2\Rector\Rules\AddPropertyTagsRector; use MSpirkov\Yii2\Rector\Yii2SetList; use Rector\Config\RectorConfig; return RectorConfig::configure() ->withPaths(...) ->withSets([ Yii2SetList::MAIN, ]) ->withConfiguredRule(AddPropertyTagsRector::class, [ 'skippedClasses' => [ 'App\Models\LegacyModel', 'App\Models\Product' => ['internalNotes'], ], 'insertBeforeTags' => ['@author', '@since'], ]);
App\Models\LegacyModel above is skipped entirely (a plain array value), while only the internalNotes property
is skipped on App\Models\Product (a class-name key mapped to a list of property names) — every other property
on it is still processed normally.
Rules at a glance
| Rule | Description |
|---|---|
| AddPropertyTagsRector | Add (or correct) @property/@property-read/@property-write tags on a yii\base\BaseObject subclass, based on its own getXxx()/setXxx() method pairs and ActiveRecord relation getters (hasOne()/hasMany()). |
| MergeModelRulesRector | Merge yii\base\Model::rules() entries that configure the same validator with the same options but a different attribute into one entry, combining their attributes into a single array (an attribute already present in another merged entry is not duplicated). |
| RemoveRedundantHtmlEncodeRector | Remove a yii\helpers\Html::encode() call whose $content argument PHPStan proves is a numeric string — digits only can't contain a character htmlspecialchars() would touch, so the call is replaced by its bare $content argument (dropping a trailing $doubleEncode argument, if present, too). |
| RemoveRedundantPropertyTagsRector | Remove a @property/@property-read/@property-write tag from a yii\base\BaseObject subclass when neither a matching public getXxx() nor setXxx() method exists (own or inherited) — typically left behind after the accessor it documented was renamed or removed. |
| ReplaceClassnameWithClassRector | Replace the deprecated yii\base\BaseObject::className() call with the native ::class constant. |
| ReplaceExistenceCheckWithExistsRector | Replace an existence check on a yii\db\QueryInterface result with the cheaper ->exists() call. |
| ReplaceFindWhereAllWithFindAllRector | Replace find()->where([...])->all() on an ActiveRecord class with the equivalent findAll([...]). |
| ReplaceFindWhereOneWithFindOneRector | Replace find()->where([...])->one() on an ActiveRecord class with the equivalent findOne([...]). |
| ReplaceGetterWithPropertyRector | Replace a yii\base\BaseObject getter call with the equivalent magic-property access, when the property is documented via a class-level @property or @property-read tag whose type matches the getter's return type, and there is no public native property of the same name (which would bypass the getter entirely) |
| ReplaceSetterWithPropertyRector | Replace a yii\base\BaseObject setter call with the equivalent magic-property assignment, when the property is documented via a class-level @property or @property-write tag whose type matches the setter's parameter type, and there is no public native property of the same name (which would bypass the setter entirely) |
| ReplaceWhereEqualityConditionWithArrayRector | Replace a single-column string where()/andWhere()/orWhere() condition (interpolated or concatenated) with the safer array condition format |
Rule reference
AddPropertyTagsRector
Add (or correct) @property/@property-read/@property-write tags on a yii\base\BaseObject subclass, based on its own getXxx()/setXxx() method pairs and ActiveRecord relation getters (hasOne()/hasMany()). A class whose __get()/__set() is overridden by something other than yii\base\BaseObject, yii\base\Component, yii\db\BaseActiveRecord, or yii\base\DynamicModel is skipped entirely, since such magic properties may not correspond to getXxx()/setXxx() methods. Configurable via skippedClasses — a plain array value (e.g. 'App\Foo') fully skips a class, while a string key mapped to a list of property names (e.g. 'App\Bar' => ['name']) skips only those properties — and insertBeforeTags, a list of PHPDoc tag names (defaulting to ['@author', '@since', '@mixin']) before which newly added @property* tags are inserted
+/** + * @property string $name The product name. + * @property-read int $price + * @property-write float $discount + */ class Product extends BaseObject { private string $_name; private int $_price; private float $_discount; /** * @return string The product name. */ public function getName(): string { return $this->_name; } /** * @param string $name The product name. */ public function setName(string $name): void { $this->_name = $name; } public function getPrice(): int { return $this->_price; } public function setDiscount(float $discount): void { $this->_discount = $discount; } }
+/** + * @property-read Customer|null $customer + * @property-read OrderItem[] $items + */ class Order extends ActiveRecord { public function getCustomer(): ActiveQuery { return $this->hasOne(Customer::class, ['id' => 'customer_id']); } public function getItems(): ActiveQuery { return $this->hasMany(OrderItem::class, ['order_id' => 'id']); } }
MergeModelRulesRector
Merge yii\base\Model::rules() entries that configure the same validator with the same options but a different attribute into one entry, combining their attributes into a single array (an attribute already present in another merged entry is not duplicated). Two entries only merge when everything after the attribute(s) — the validator and any options — is identical; a rules() body that isn't a single return [...] of literal rule arrays is left untouched
class LoginForm extends Model
{
public function rules(): array
{
return [
- ['login', 'required'],
- ['password', 'required'],
+ [['login', 'password'], 'required'],
];
}
}
RemoveRedundantHtmlEncodeRector
Remove a yii\helpers\Html::encode() call whose $content argument PHPStan proves is a numeric string — digits only can't contain a character htmlspecialchars() would touch, so the call is replaced by its bare $content argument (dropping a trailing $doubleEncode argument, if present, too). Any other $content is left untouched
<?php /** * @var numeric-string $id * @var string $name */ ?> -<?= Html::encode($id) ?> +<?= $id ?> <?= Html::encode($name) ?>
RemoveRedundantPropertyTagsRector
Remove a @property/@property-read/@property-write tag from a yii\base\BaseObject subclass when neither a matching public getXxx() nor setXxx() method exists (own or inherited) — typically left behind after the accessor it documented was renamed or removed. A tag backed by at least one accessor is left untouched even if it names the wrong direction (e.g. @property-read with only a setter) — correcting it to match the accessor that does exist is AddPropertyTagsRector's job, not this rule's, so the two never touch the same tag. A class whose __get()/__set() isn't the one inherited from yii\base\BaseObject or yii\base\Component — own override or inherited from some other ancestor, including yii\db\BaseActiveRecord and yii\base\DynamicModel — is skipped entirely, since its magic properties aren't necessarily backed by getter/setter methods. Configurable via skippedClasses — a plain array value (e.g. 'App\Foo') fully skips a class, while a string key mapped to a list of property names (e.g. 'App\Bar' => ['name']) skips only those properties
/**
* @property string $name
- * @property-read int $legacyCount
*/
class Product extends BaseObject
{
private string $_name;
public function getName(): string
{
return $this->_name;
}
public function setName(string $name): void
{
$this->_name = $name;
}
}
ReplaceClassnameWithClassRector
Replace the deprecated yii\base\BaseObject::className() call with the native ::class constant. self::className() and parent::className() are left untouched, since both are late-static-binding forwarding calls not generally equivalent to self::class/parent::class once the class is subclassed — only static::className() and an explicit class name are rewritten
-$class = SomeClass::className(); -$class = static::className(); +$class = SomeClass::class; +$class = static::class;
ReplaceExistenceCheckWithExistsRector
Replace an existence check on a yii\db\QueryInterface result with the cheaper ->exists() call. Recognises a ->count() comparison against the boundary literals 0/1 (in either operand order) and a strict ->one() !== null / ->one() === null check. A check that means "no rows" (e.g. count() < 1, one() === null) is rewritten to the negated !exists(), not exists(). Only the boundary comparisons that map unambiguously onto a presence/absence question are recognised — count() > 1, for instance, is left untouched
public function emailIsTaken(string $email): bool
{
- return User::find()->where(['email' => $email])->one() !== null;
+ return User::find()->where(['email' => $email])->exists();
}
public function emailIsAvailable(string $email): bool
{
- return User::find()->where(['email' => $email])->count() < 1;
+ return !User::find()->where(['email' => $email])->exists();
}
ReplaceFindWhereAllWithFindAllRector
Replace find()->where([...])->all() on an ActiveRecord class with the equivalent findAll([...]). Only fires when the where() condition is a literal array keyed entirely by string literals: findAll() treats any other condition shape (scalar, list, Expression) as a primary key lookup instead of forwarding it to where() unchanged, so those shapes are intentionally left untouched.
-$customers = Customer::find()->where(['status' => 1])->all(); +$customers = Customer::findAll(['status' => 1]);
ReplaceFindWhereOneWithFindOneRector
Replace find()->where([...])->one() on an ActiveRecord class with the equivalent findOne([...]). Only fires when the where() condition is a literal array keyed entirely by string literals: findOne() treats any other condition shape (scalar, list, Expression) as a primary key lookup instead of forwarding it to where() unchanged, so those shapes are intentionally left untouched.
-$customer = Customer::find()->where(['status' => 1])->one(); +$customer = Customer::findOne(['status' => 1]);
ReplaceGetterWithPropertyRector
Replace a yii\base\BaseObject getter call with the equivalent magic-property access, when the property is documented via a class-level @property or @property-read tag whose type matches the getter's return type, and there is no public native property of the same name (which would bypass the getter entirely)
/**
* @property-read string $prop
*/
class Example extends BaseObject
{
private string $_prop;
public function getProp(): string
{
return $this->_prop;
}
}
-$value = (new Example())->getProp();
+$value = (new Example())->prop;
ReplaceSetterWithPropertyRector
Replace a yii\base\BaseObject setter call with the equivalent magic-property assignment, when the property is documented via a class-level @property or @property-write tag whose type matches the setter's parameter type, and there is no public native property of the same name (which would bypass the setter entirely)
/**
* @property-write string $prop
*/
class Example extends \yii\base\BaseObject
{
private string $_prop;
public function setProp(string $value): void
{
$this->_prop = $value;
}
}
-(new Example())->setProp('value');
+(new Example())->prop = 'value';
ReplaceWhereEqualityConditionWithArrayRector
Replace a single-column string where()/andWhere()/orWhere() condition (interpolated or concatenated) with the safer array condition format
-$query->where("column = $value"); -$query->andWhere('column = ' . $value); +$query->where(['column' => $value]); +$query->andWhere(['column' => $value]);