Stop Silent Overwrites: Optimistic Locking in Yii 2 ActiveRecord
Yii 2 ActiveRecord optimistic locking prevents lost updates by adding a version column, exposing it in the form, and catching StaleObjectException as a user‑facing conflict.
03 Sept 2025, 21:47 UTC

The bug that leaves no trace
Two admins open the same product edit screen. Admin A saves a price change. Admin B, who loaded the form earlier, saves a description change. B’s update lands last, and A’s price change vanishes. No error, no log entry, no exception. This is the classic lost update, and the fix in Yii 2 is a one‑line method on your model.
Yii 2 ActiveRecord has built‑in optimistic locking: a version column that increments with each save, checked in the UPDATE’s WHERE clause. If the row changed since you loaded it, Yii throws yii\\db\\StaleObjectException instead of writing. This article targets Yii 2 (2.0.x); Yii 3’s data layer differs, so check your installed version before copying code.
Enabling the lock
Optimistic locking needs three things: a version column, a model that names it, and the version round‑tripping through your form.
First, a migration adding the column:
public function safeUp()
{
$this->addColumn('{{%product}}', 'version', $this->integer()->notNull()->defaultValue(0));
}Then the model override:
public function optimisticLock()
{
return 'version';
}From then on, save() and update() include version = 0 in the UPDATE statement and increment the column. If the condition matches zero rows, Yii throws StaleObjectException. The exception is your signal that someone else saved first.
Finally, the form must carry the version back. Add a hidden input to your edit view:
<?= $form->field($model, 'version')->hiddenInput()->label(false) ?>When the user submits, the controller loads the current record, checks the version, and writes. If the version has moved on, the exception fires before the UPDATE executes.
Catching the conflict
The exception is the easy part; the useful part is what you show the user. A conflict response should reload the stored row and ask the user to reapply their changes:
public function actionUpdate($id)
{
$model = Product::findOne($id);
$request = Yii::$app->request;
if ($request->isPost) {
if ($model->load($request->post())) {
try {
if ($model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
}
} catch (StaleObjectException $e) {
Yii::$app->session->setFlash('conflict', 'This record was modified by someone else. Please review the current values and try again.');
$model->refresh(); // reload latest values
}
}
}
return $this->render('update', ['model' => $model]);
}The user sees the conflict, not a silent overwrite. The key is the refresh() call: it reloads the model with the latest values, so the form shows what is actually in the database. The user can then decide whether to reapply their edits.
When it doesn’t help
Optimistic locking only protects ActiveRecord’s save()/update()/delete() path. Bulk operations like updateAll() and deleteAll() bypass it. Raw SQL needs its own version predicate. It also doesn’t replace authorization, validation, or database constraints. It only detects concurrent modification of the same row.
Before deploying, run a two‑request simulation: load the form twice, submit the first, then submit the second. The second request should throw StaleObjectException or return the conflict response. Check Yii’s debug toolbar to confirm the UPDATE includes the version predicate. If your edit screen uses updateAll() or raw SQL, you’ll need to add the version check manually.
Try it on your highest‑risk screen
Pick the edit screen where a lost update would hurt most. Add the version column, override optimisticLock(), catch the exception, and test with a scripted double‑submit. The conflict UI should be simple: show current stored values and ask the user to reapply changes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.