Yii ActiveRecord to Eloquent: a side-by-side translation

Relations, scopes, behaviors, validation, events, and queries: what each Yii ActiveRecord idiom becomes in Eloquent, with the two of them next to each other so you can port a model in one sitting.

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.php attributes, or labels in the Vue component. Eloquent doesn't own labels.
  • Yii2 TypecastBehavior / manual casting → $casts. Backed enums are the big win: 'status' => OrderStatus::class and 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

  1. Table name, primary key, timestamps, $dateFormat if Yii stored unix ints.
  2. Relations, with the key order double-checked.
  3. Scopes; default scope → global scope or SoftDeletes.
  4. Behaviors → casts, traits, or an observer.
  5. rules() → Form Request(s); safe$fillable.
  6. Lifecycle hooks → events/observer; slow side effects → queued jobs.
  7. Labels → lang file; API fields() → Resource.
  8. 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.

Kelly Brintle

Principal Architect at CraftWeb. I move legacy PHP applications to Laravel and Vue and build platforms for businesses that run on them. More about me