Laravel: Optimistic Locking in one line
Timestamps are our saviors
Laravel supports Pessimistic Locking out of the box. In other words, the application can ask the database to “lock” some rows from any change until an update on those records occur. That’s great, until you get deadlocks for not wrapping them into a transaction.
Sometimes, Pessimistic Locking just works, but it’s not a silver bullet. Imagine someone trying to edit a Post, only to see its dozens of paragraphs gone because the editor, who visited the editor before the user, also decided to update the article.
That problem can be solved with some Optimistic Locking.
Luckily for us, Laravel’s Eloquent ORM has all the necessary tools to implement Optimistic Locking without having to download third party packages or even change our Eloquent Models to a point of becomes a hindrance to maintain.
The problematic, and the solution
Let’s imagine we are editing a “Post”, at the same time the main editor is also editing it. The editor retrieves the Post from the server at 14:50, but we started to edit after 15:00 and finished at 15:40.
As you can guess, if the editor changes one letter and presses the “Update” after we finished, all of our changes will be overwritten for what the Editor has in its browser.
Please, don’t think too much about example, is just for illustration. You should save the changes separately so these can be recovered later, and offer a difference between the changes and the data in the database, if possible.
You may often see on the internet Optimistic Locking examples using a version number, or even a hash, but here we already have something we can use, out of the box. Every time an Eloquent Model gets updated in the database, the updated_at
column gets updated with the current timestamp — as long you have not removed it from the model declaration.
echo $post->updated_at; // 2024-01-01 15:50:00
$post->update(['title' => 'I altered the deal']);
echo $post->updated_at; // 2024-12-12 16:30:54
In other words, if the updated_at
timestamp has changed (by become more recent than what we have), then we’re dealing with stale data. This is the key, since Optimistic Locking requires a way to say if the record data changed.
In our application, we want to retrieve the model using Route Model Binding, but also the timestamp as a UNIX Epoch which we can later compare to the model. If the timestamp of the data we retrieved is equal to the column in the database, then we can safely update the model since nobody did it previously.
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::patch('post/{post}', function (Post $post, Request $request) {
// Validate the request having a UNIX Epoch timestamp ("U" format)
$request->validate([
'updated_at' => 'required|timestamp:U'
]);
// Check the post has the same timestamp. If not, it was
// already updated elsewhere and the data may be stale.
if ($post->updated_at->notEqual($request->int('timestamp')) {
return back()->withErrors([
'save' => 'The Post changed before updating it. Refreshing'
])->withInput(['body', 'text']);
}
// Validate the request data to fill in the post.
$validated = $request->validate([
// ...
]);
// Save the model with the validated data.
$post->update($validated);
return back();
});
This may suffice for small applications. While plausible, it will be difficult to see two or more users trying to update the same resource.
Savvy developers will note that, for a resource being constantly modified by more than one application instance, there is a slight window between the check and the update where stale data can overwrite the newer data. It’s not the end of the world; we can fix this.
With a little more code, we can make an Optimistic Lock by doing a check and direct update to the row on the database in one query. We only need to add a WHERE
clause where the updated_at
column matches what we are receiving as part of the request.
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::patch('post/{post}', function (Post $post, Request $request) {
// Validate the request having a UNIX Epoch timestamp
$request->validate([
'updated_at' => 'required|timestamp:s'
]);
// Validate the data from the request.
$validated = $request->validate([
// ...
])
// Update the post, and return how many rows were affected.
$updated = Post::whereKey($post->id)
->where('updated_at', $request->date('updated_at'))
->update($validated);
// If there was 0 rows affected, then the post was previously updated.
if (!$updated) {
return back()->withErrors([
'save' => 'The Post changed before updating it.'
])->withInput(['body', 'text']);
}
return back();
}
The above example won’t fire any Eloquent Event, which is the tradeoff unless you want to fire it manually. That can be done easily using the event()
helper, just after syncing the attributes changed.
// Fire the "updated" event with the fresh updated post.
event("eloquent.updated: ".Post::class, $post->fresh());
Clearly this is great alternative to using Pessimistic Locking inside a Database Transaction, because there is still a (tiny) window of time where the database row can change in regard of the data received.