Models are where most of the migration hours go, so it's worth having the translation table in one place. This is the reference I use when moving a module in a phased Yii to Laravel migration: each Yii ActiveRecord idiom, next to what it becomes in Eloquent. Examples show Yii2 first and Yii1 where it differs.
One rule up front, because both apps share the database during the migration: port the model to the schema as it is. Table prefixes, odd column names, create_time instead of created_at. Eloquent can be configured around all of it, and renaming comes later, after Yii is gone.
Table name, primary key, timestamps
Yii2
class Order extends \yii\db\ActiveRecord
{
public static function tableName() { return '{{%order}}'; } // prefix from db config
public function behaviors()
{
return [[
'class' => \yii\behaviors\TimestampBehavior::class,
'createdAtAttribute' => 'create_time',
'updatedAtAttribute' => 'update_time',
]];
}
}
Yii1
class Order extends CActiveRecord
{
public function tableName() { return 'tbl_order'; }
public function primaryKey() { return 'order_id'; }
}
Eloquent
class Order extends Model
{
protected $table = 'tbl_order';
protected $primaryKey = 'order_id';
const CREATED_AT = 'create_time';
const UPDATED_AT = 'update_time';
// Yii2 TimestampBehavior stores unix ints by default; tell Eloquent.
protected $dateFormat = 'U';
}
If the table has no timestamp columns at all, public $timestamps = false;. If Yii1 used a beforeSave() to set create_time = new CDbExpression('NOW()'), Eloquent's default timestamps do the same thing and you can delete that hook.
Relations
Yii2
public function getCustomer() { return $this->hasOne(Customer::class, ['id' => 'customer_id']); }
public function getItems() { return $this->hasMany(OrderItem::class, ['order_id' => 'id']); }
public function getTags()
{
return $this->hasMany(Tag::class, ['id' => 'tag_id'])->viaTable('order_tag', ['order_id' => 'id']);
}
Yii1
public function relations()
{
return [
'customer' => [self::BELONGS_TO, 'Customer', 'customer_id'],
'items' => [self::HAS_MANY, 'OrderItem', 'order_id'],
'tags' => [self::MANY_MANY, 'Tag', 'tbl_order_tag(order_id, tag_id)'],
'itemCount'=> [self::STAT, 'OrderItem', 'order_id'],
];
}
Eloquent
public function customer(): BelongsTo { return $this->belongsTo(Customer::class, 'customer_id'); }
public function items(): HasMany { return $this->hasMany(OrderItem::class, 'order_id', 'order_id'); }
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class, 'tbl_order_tag', 'order_id', 'tag_id');
}
// Yii1 STAT relation → withCount()
// Order::withCount('items')->get(); → $order->items_count
Watch the key order. Yii2's ['id' => 'customer_id'] reads their key => our key; Eloquent's belongsTo(Related::class, $foreignKey, $ownerKey) reads our key, their key. Getting this backwards is the most common port bug and it fails silently with empty relations.
Eager loading is the same idea in both: Yii's ->with('customer', 'items') is Eloquent's ->with(['customer', 'items']). Yii2's joinWith() has no direct twin; use with() plus whereHas() for filtering, or an explicit join() when you actually need the SQL join.
Scopes and default scopes
Yii2 (a custom query class)
class OrderQuery extends \yii\db\ActiveQuery
{
public function paid() { return $this->andWhere(['status' => 'paid']); }
public function recent() { return $this->orderBy(['create_time' => SORT_DESC]); }
}
// Order::find()->paid()->recent()->all();
Yii1
public function scopes()
{
return ['paid' => ['condition' => "status = 'paid'"], 'recent' => ['order' => 'create_time DESC']];
}
public function defaultScope() { return ['condition' => 'deleted = 0']; }
// Order::model()->paid()->recent()->findAll();
Eloquent
public function scopePaid(Builder $q): Builder { return $q->where('status', 'paid'); }
public function scopeRecent(Builder $q): Builder { return $q->orderByDesc('create_time'); }
// Order::paid()->recent()->get();
A Yii1 defaultScope() becomes a global scope. If it's the classic deleted = 0, don't write it by hand; use SoftDeletes with const DELETED_AT = 'deleted_at' if the column is a timestamp, or a tiny global scope if it's a boolean flag:
protected static function booted(): void
{
static::addGlobalScope('notDeleted', fn (Builder $q) => $q->where('deleted', 0));
}
Remember Yii's resetScope() when porting: any query that relied on it becomes withoutGlobalScope('notDeleted').
Behaviors
Behaviors are the concept with no single equivalent, so look at what each one does:
| Yii behavior does… | Eloquent |
|---|---|
| Sets timestamps | built-in timestamps |
Sets created_by / updated_by (BlameableBehavior) |
an observer or a creating/updating model event |
Generates a slug (SluggableBehavior) |
a saving event, or spatie/laravel-sluggable |
| Serializes an attribute to JSON | $casts = ['meta' => 'array'] |
| Adds reusable methods to many models | a trait |
| Soft delete | SoftDeletes |
// BlameableBehavior → observer
class OrderObserver
{
public function creating(Order $order): void { $order->created_by ??= auth()->id(); }
public function updating(Order $order): void { $order->updated_by = auth()->id(); }
}
Register it in a service provider with Order::observe(OrderObserver::class), or use the #[ObservedBy] attribute on the model.
Validation: rules() becomes a Form Request
This is the change that feels wrong to Yii developers and is right anyway. Yii validates the record; Laravel validates the request. Rules move from the model to a FormRequest next to the controller that accepts the input.
Yii2
public function rules()
{
return [
[['customer_id', 'status'], 'required'],
['status', 'in', 'range' => ['pending', 'paid', 'shipped']],
['email', 'email'],
['total', 'number', 'min' => 0],
['customer_id', 'exist', 'targetClass' => Customer::class, 'targetAttribute' => 'id'],
['notes', 'safe'],
];
}
Laravel
class StoreOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'customer_id' => ['required', 'exists:customers,id'],
'status' => ['required', Rule::in(['pending', 'paid', 'shipped'])],
'email' => ['nullable', 'email'],
'total' => ['required', 'numeric', 'min:0'],
'notes' => ['nullable', 'string'],
];
}
}
Yii's scenario concept (different rules for create vs update) becomes two Form Requests, which is clearer than it sounds. safe attributes become $fillable on the model, and that's the only validation-adjacent thing that stays on the model.
If the Yii model has custom validator methods (validateCoupon()), they become custom Rule objects or closures in the Form Request. If the same validation must run from a console command or a queued job, extract it into a small service class both call, rather than reaching for model validation.
Events and lifecycle hooks
| Yii | Eloquent |
|---|---|
beforeValidate() |
none; validation isn't on the model |
beforeSave($insert) |
saving, or creating / updating |
afterSave($insert, $changedAttributes) |
saved / created / updated; use $model->wasChanged('status') for the changed-attributes check |
beforeDelete() / afterDelete() |
deleting / deleted |
afterFind() |
retrieved |
Put anything beyond a one-liner in an observer class rather than booted(). A Yii afterSave that sent an email becomes a created event that dispatches a queued job; that alone fixes a lot of slow Yii admin screens.
Finding and querying
| Yii2 | Yii1 | Eloquent |
|---|---|---|
Order::findOne($id) |
Order::model()->findByPk($id) |
Order::find($id) |
Order::findOne(['sku' => $s]) |
findByAttributes(['sku' => $s]) |
Order::where('sku', $s)->first() |
Order::find()->where([...])->all() |
findAll($criteria) |
Order::where(...)->get() |
->one() / ->all() |
find() / findAll() |
->first() / ->get() |
->asArray() |
– | ->toArray() on results, or DB::table() for plain arrays |
->count() |
count() |
->count() |
Order::find()->batch(500) |
– | ->chunk(500) / ->lazy() |
ActiveDataProvider + GridView |
CActiveDataProvider |
->paginate(25) + a Vue table |
Yii1's CDbCriteria with raw condition strings deserves a careful read when porting: raw SQL fragments often hide parameter injection risks and column names that only work because of a join elsewhere. Rewrite them as builder calls; don't paste the string into whereRaw().
Attribute labels, casting, and accessors
attributeLabels()→lang/en/validation.phpattributes, or labels in the Vue component. Eloquent doesn't own labels.- Yii2
TypecastBehavior/ manual casting →$casts. Backed enums are the big win:'status' => OrderStatus::classand the string in the database stays exactly what Yii wrote. - Yii1 getters that computed values (
getFullName()) → Eloquent accessors (protected function fullName(): Attribute). - Yii2's
fields()/extraFields()for API output → API Resources. Don't put presentation on the model.
A whole model, ported
Yii2:
class Order extends ActiveRecord
{
public static function tableName() { return '{{%order}}'; }
public function behaviors() { return [TimestampBehavior::class]; }
public function rules() { /* … */ }
public function getCustomer() { return $this->hasOne(Customer::class, ['id' => 'customer_id']); }
public function getItems() { return $this->hasMany(OrderItem::class, ['order_id' => 'id']); }
public static function find() { return new OrderQuery(static::class); }
public function afterSave($insert, $changed)
{
parent::afterSave($insert, $changed);
if (isset($changed['status']) && $this->status === 'shipped') {
Yii::$app->mailer->compose('shipped', ['order' => $this])->setTo($this->customer->email)->send();
}
}
}
Eloquent:
#[ObservedBy(OrderObserver::class)]
class Order extends Model
{
protected $table = 'tbl_order';
protected $fillable = ['customer_id', 'status', 'total', 'notes'];
protected $casts = ['status' => OrderStatus::class, 'total' => 'decimal:2'];
public function customer(): BelongsTo { return $this->belongsTo(Customer::class); }
public function items(): HasMany { return $this->hasMany(OrderItem::class); }
public function scopePaid(Builder $q): Builder { return $q->where('status', OrderStatus::Paid); }
}
class OrderObserver
{
public function updated(Order $order): void
{
if ($order->wasChanged('status') && $order->status === OrderStatus::Shipped) {
SendShippedEmail::dispatch($order); // queued, retried, off the request
}
}
}
Validation is in StoreOrderRequest / UpdateOrderRequest. The TimestampBehavior is gone because Eloquent does it. The email is a queued job instead of a synchronous send inside a save.
Porting checklist per model
- Table name, primary key, timestamps,
$dateFormatif Yii stored unix ints. - Relations, with the key order double-checked.
- Scopes; default scope → global scope or
SoftDeletes. - Behaviors → casts, traits, or an observer.
rules()→ Form Request(s);safe→$fillable.- Lifecycle hooks → events/observer; slow side effects → queued jobs.
- Labels → lang file; API
fields()→ Resource. - A characterization test that writes a row through Laravel and reads it through the still-running Yii code (or vice versa) for any table both apps touch.
When to get help
If the Yii models are thin and the schema is sane, this is a mechanical job any Laravel developer can do with this table open. It gets hard when models carry business logic in beforeSave chains, when default scopes hide rows in ways nobody remembers, or when the same table has two models with different rules. Those are the codebases where a two-week assessment pays for itself before the first module moves.