Éviter les écrasements concurrents avec l’optimistic locking Eloquent
Implémente l’optimistic locking Eloquent (verrou optimiste) pour éviter les mises à jour concurrentes. Trait réutilisable, gestion du 409, retry et tests.
Sommaire
Éviter les écrasements concurrents avec l’optimistic locking Eloquent
Quand plusieurs utilisateurs ou processus modifient la même ligne au même moment, la dernière écriture gagne et peut écraser silencieusement des changements. Ce tutoriel montre comment ajouter un verrou optimiste (optimistic locking) avec une colonne version dans Eloquent, via un trait réutilisable, avec gestion d’erreur HTTP 409, patterns d’usage côté API et formulaires, stratégie de retry dans les jobs et tests automatisés.
Objectif
L’objectif est d’implémenter un verrou optimiste générique, activable sur n’importe quel modèle Eloquent. Concrètement, on ajoute une colonne version incrémentée à chaque mise à jour et on modifie la requête UPDATE pour vérifier que la version en base correspond à la version lue au moment du chargement du modèle. En cas de conflit (mismatch), on lève une exception dédiée, on renvoie un 409 côté API, et on propose des stratégies de résolution côté client et côté jobs (retry/backoff). Des tests vont valider le comportement concurrent et l’incrément de version.
Objectif et prérequis
Le problème classique est le suivant: deux updates simultanés sur la même ligne écrasent des données sans alerte. Par exemple, Alice et Bob chargent le même article, Alice enregistre d’abord, puis Bob enregistre à son tour et remplace sans le savoir les changements d’Alice. La solution consiste à ajouter une colonne version et à vérifier, au moment de l’UPDATE, que la version en base n’a pas changé entre la lecture et l’écriture; si elle a changé, la sauvegarde échoue avec une erreur gérable.
Il te faut Laravel 10 ou 11, MySQL ou PostgreSQL, l’utilisation standard d’Eloquent, et quelques notions de transactions. Les exemples utilisent PHP 8.1+, Artisan et des conventions Laravel récentes.
Migration: ajouter la colonne de version
Commence par générer une migration pour ajouter la colonne version sur les tables à protéger. Par exemple, pour les posts, lance la commande Artisan suivante:
php artisan make:migration add_version_to_posts_table
Dans la migration générée, ajoute une colonne entière non signée avec une valeur par défaut à 0 et un index pour des recherches éventuelles. Voici un exemple complet prêt à l’emploi:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->unsignedBigInteger('version')->default(0)->after('updated_at');
$table->index('version');
});
}
public function down(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->dropIndex(['version']);
$table->dropColumn('version');
});
}
};
Le backfill n’est pas nécessaire grâce au default(0), ce qui permet de déployer sans opération coûteuse. Après déploiement, exécute la migration:
php artisan migrate
Si ta base est soumise à de fortes écritures, planifie une courte fenêtre de maintenance lors de l’ajout des colonnes pour éviter des erreurs d’écriture pendant la migration. Dans la plupart des cas, l’ajout d’une colonne avec valeur par défaut et index reste rapide.
Trait Eloquent OptimisticLocking
L’implémentation se fait via un trait qui surcharge le chemin de mise à jour du modèle afin d’ajouter la condition WHERE version = :original_version et d’incrémenter la version en une seule requête. Crée le fichier App/Support/OptimisticLocking.php avec le contenu suivant:
<?php
namespace App\Support;
use Illuminate\Database\Eloquent\Builder;
use RuntimeException;
trait OptimisticLocking
{
/**
* Surcharge du chemin d'update pour imposer la condition sur la version.
*/
protected function performUpdate(Builder $query)
{
// Aucune modification => rien à faire
if (!$this->isDirty()) {
return true;
}
// Détermine la version d'origine lue depuis la base
$originalVersion = $this->getOriginal('version');
// Par sécurité, on refuse un update sans version connue
if ($originalVersion === null) {
throw new OptimisticLockingException($this);
}
// Incrémente la version localement pour cette sauvegarde
$this->version = $originalVersion + 1;
// Prépare les colonnes modifiées, y compris 'version'
$dirty = $this->getDirty();
$dirty['version'] = $this->version;
// Applique les clés du modèle et la condition sur la version d'origine
$this->setKeysForSaveQuery($query);
$query->where('version', $originalVersion);
// Tente la mise à jour atomique
$updated = $query->update($dirty);
if ($updated === 0) {
// Aucun enregistrement mis à jour => conflit de version
throw new OptimisticLockingException($this);
}
return true;
}
}
Ajoute ensuite l’exception App/Support/OptimisticLockingException, afin d’identifier proprement ce cas:
<?php
namespace App\Support;
use Illuminate\Database\Eloquent\Model;
use RuntimeException;
class OptimisticLockingException extends RuntimeException
{
public function __construct(public readonly Model $model)
{
parent::__construct('Optimistic lock failed for ' . get_class($model) . '#' . $model->getKey());
}
}
Active le trait dans les modèles critiques. Par exemple pour Post:
<?php
namespace App\Models;
use App\Support\OptimisticLocking;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use OptimisticLocking;
// Cast pratique pour éviter les surprises
protected $casts = [
'version' => 'int',
];
// Choisis soit fillable, soit guarded selon ta politique
// protected $fillable = ['title', 'content', 'version'];
protected $guarded = ['id'];
}
Garantis que la colonne version est bien lue/écrite par Eloquent. Si tu préfères ne pas la rendre mass-assignable, garde-la dans guarded et utilise forceFill quand tu dois injecter explicitement la version depuis une requête.
Utilisation en écriture: patterns concrets
Le flux classique de lecture-modification-enregistrement fonctionne sans changement côté code. Si quelqu’un a déjà modifié la ressource entre-temps, l’exception OptimisticLockingException sera levée au moment du save. Par exemple, dans un contrôleur:
<?php
// Chargement + modification + save
$post = Post::findOrFail($id);
$post->title = $request->string('title');
$post->content = $request->string('content');
$post->save(); // lève OptimisticLockingException si la version ne correspond plus
Les updates partiels via fill/merge se comportent pareil et respectent la contrainte de version:
<?php
$post = Post::findOrFail($id);
$post->fill($request->validate([
'title' => ['sometimes', 'string'],
'content' => ['sometimes', 'string'],
]));
$post->save();
Pour les mises à jour en masse (bulk updates) qui n’instancient pas de modèle et qui contournent les événements/traits, ajoute manuellement la condition WHERE version = ? et incrémente la version en base dans la même requête. Voici un exemple robuste:
<?php
use Illuminate\Support\Facades\DB;
$expected = (int) $request->integer('version');
$updated = Post::whereKey($id)
->where('version', $expected)
->update([
'title' => $request->string('title'),
'content' => $request->string('content'),
'version' => DB::raw('version + 1'),
'updated_at' => now(),
]);
if ($updated === 0) {
throw new \App\Support\OptimisticLockingException(
Post::make(['id' => $id, 'version' => $expected])
);
}
API: renvoyer un 409 et la version courante
Dans un contexte API, renvoie un HTTP 409 Conflict quand une collision est détectée, tout en donnant au client la version courante pour qu’il décide de recharger, merger ou retenter. Une gestion simple dans App/Exceptions/Handler.php consiste à intercepter l’exception et produire un JSON cohérent:
<?php
namespace App\Exceptions;
use App\Support\OptimisticLockingException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Throwable;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e)
{
if ($e instanceof OptimisticLockingException) {
$model = $e->model;
$fresh = $model->newQuery()->find($model->getKey());
return response()->json([
'message' => 'Conflit de version',
'current_version' => optional($fresh)->version,
'resource' => $fresh,
], 409);
}
return parent::render($request, $e);
}
}
Côté client, fais transiter la version dans le payload et, en cas de 409, recharge ou fusionne. Par exemple en JavaScript avec fetch:
async function updatePost(id, payload) {
const res = await fetch(`/api/posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload), // inclut { title, content, version }
});
if (res.status === 409) {
const data = await res.json();
// Décide : recharger, afficher un diff, proposer un merge, puis retenter
console.warn('Conflit de version, version courante:', data.current_version);
return { conflict: true, server: data.resource };
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return await res.json();
}
Formulaires web: champ caché version et UX de conflit
Dans un formulaire Blade, inclue la version en champ caché pour que le contrôleur puisse l’injecter au moment de la sauvegarde. Par exemple dans resources/views/posts/edit.blade.php:
{{-- champs visibles ... --}}
<input type="hidden" name="version" value="{{ $post->version }}">
Au moment de la soumission, récupère la version et force-la sur le modèle avant save. En cas d’OptimisticLockingException, redirige avec un message clair et, si possible, affiche les valeurs courantes en base pour aider à résoudre le conflit.
<?php
use App\Models\Post;
use App\Support\OptimisticLockingException;
use Illuminate\Http\Request;
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string'],
'version' => ['required', 'integer'],
]);
// Force la version depuis le formulaire, même si non fillable
$post->forceFill([
'title' => $validated['title'],
'content' => $validated['content'],
'version' => (int) $validated['version'],
]);
try {
$post->save();
} catch (OptimisticLockingException $e) {
$fresh = $post->newQuery()->find($post->getKey());
return back()
->withInput()
->with('conflict_current', $fresh)
->withErrors([
'title' => 'Conflit de modification: la ressource a été modifiée par quelqu’un d’autre. Compare tes changements avec la version actuelle.',
]);
}
return to_route('posts.show', $post)->with('status', 'Article mis à jour');
}
Une UX agréable consiste à afficher la version en base (conflict_current) à côté des valeurs soumises, ou à générer un diff textuel pour guider l’utilisateur.
Jobs et transactions: retry avec backoff
Dans un job qui applique des changements calculés en arrière-plan, attrape l’OptimisticLockingException et réessaie avec un backoff exponentiel et un jitter pour éviter les collisions répétées. Enveloppe les opérations sensibles dans une transaction pour conserver la cohérence locale.
<?php
namespace App\Jobs;
use App\Models\Post;
use App\Support\OptimisticLockingException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
class UpdatePostTitle implements ShouldQueue
{
use Queueable;
public function __construct(public readonly int $postId, public readonly string $title) {}
public function handle(): void
{
$this->retryWithBackoff(3, 50, function () {
DB::transaction(function () {
$post = Post::findOrFail($this->postId);
// Optionnel: si tu as aussi des agrégations locales critiques, tu peux combiner
// un verrou pessimiste, mais l'optimistic locking suffit généralement :
// $post = Post::whereKey($this->postId)->lockForUpdate()->first();
$post->title = $this->title;
$post->save(); // peut lever OptimisticLockingException
});
});
}
private function retryWithBackoff(int $attempts, int $baseMs, callable $callback): void
{
$last = null;
for ($i = 0; $i < $attempts; $i++) {
try {
$callback();
return;
} catch (OptimisticLockingException $e) {
$last = $e;
if ($i === $attempts - 1) {
report($e);
throw $e;
}
$jitterMs = random_int(0, $baseMs);
$sleepMs = (int) ($baseMs * (2 ** $i) + $jitterMs);
usleep($sleepMs * 1000);
}
}
if ($last) {
throw $last;
}
}
}
Fixe toujours un nombre de tentatives maximal et journalise les collisions pour le monitoring. Un jitter aléatoire réduit le risque de synchronisation des retries entre plusieurs workers.
Tests: valider le comportement concurrent
Teste qu’une sauvegarde échoue quand deux instances du même modèle tentent d’écrire successivement et que la version s’incrémente bien à chaque update. Voici un exemple de tests PHPUnit simples.
<?php
namespace Tests\Feature;
use App\Models\Post;
use App\Support\OptimisticLockingException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class OptimisticLockingTest extends TestCase
{
use RefreshDatabase;
public function test_second_save_fails_when_versions_conflict(): void
{
$post = Post::create(['title' => 'T0', 'content' => 'C0']);
$a = Post::find($post->id);
$b = Post::find($post->id);
$a->title = 'A';
$a->save(); // OK -> version: 0 -> 1
$b->title = 'B';
$this->expectException(OptimisticLockingException::class);
$b->save(); // lève exception (version de b n'est plus à jour)
}
public function test_version_increments_on_each_successful_save(): void
{
$post = Post::create(['title' => 'T0', 'content' => 'C0']);
$this->assertSame(0, $post->version);
$post->title = 'T1';
$post->save();
$this->assertSame(1, $post->version);
$post->title = 'T2';
$post->save();
$this->assertSame(2, $post->version);
}
}
Pour stresser davantage la condition, tu peux utiliser Parallel Testing de Laravel pour déclencher des writes simultanés ou orchestrer deux connexions DB distinctes qui tentent un update concurrent.
Cas particuliers et limites
Avec les soft deletes, si tu modifies performDelete ou que tu fais des suppressions conditionnelles, applique le même principe en ajoutant WHERE version = :original_version à la requête de delete pour éviter d’effacer une version déjà modifiée. À défaut de surcharge, effectue la suppression via un query builder avec la contrainte sur la version.
Les mises à jour massives Eloquent via ->update([...]) contournent les événements et les traits de modèle, donc ton OptimisticLocking ne s’appliquera pas automatiquement. Dans ces cas, ajoute manuellement WHERE version = ? et l’incrément version = version + 1, comme montré plus haut pour les bulk updates.
Réutiliser updated_at comme version est tentant mais fragile. Les horodatages varient selon la précision, le format et parfois la timezone; de plus, deux updates très rapprochés peuvent aboutir à la même valeur selon la précision. Un entier monotone (unsigned big integer) est prédictible et fiable.
En contexte de réplication master/read, vérifie que tu lis sur la même connexion que l’écriture juste après un write (read-after-write) pour éviter de lire une version obsolète depuis un replica. Désactive temporairement la lecture via réplicas pour la requête de rafraîchissement, ou force la connexion maître.
Pour les ETL, exports ou upserts idempotents, inclue la version dans les flux de données. Côté consommateur, impose la contrainte sur la version pour éviter d’ingérer des données périmées et permets des retries contrôlés.
Bonus: ETag et If-Match pour REST
Expose un ETag dérivé de la version côté GET et exige un en-tête If-Match cohérent côté PUT/PATCH pour offrir un contrôle de concurrence standard HTTP.
Pour la lecture, renvoie l’ETag basé sur la version:
<?php
use App\Models\Post;
public function show(Post $post)
{
return response()
->json($post)
->header('ETag', '"v' . $post->version . '"');
}
Pour la mise à jour, valide If-Match et mappe-le sur la version attendue. Si l’ETag ne correspond pas, renvoie 412 Precondition Failed (ou 409).
<?php
use App\Models\Post;
use App\Support\OptimisticLockingException;
use Illuminate\Http\Request;
public function update(Request $request, Post $post)
{
$ifMatch = $request->header('If-Match');
if (!$ifMatch || !preg_match('/^"v(?P<v>\d+)"$/', $ifMatch, $m)) {
return response()->json(['message' => 'If-Match requis'], 428); // 428 Precondition Required
}
$expectedVersion = (int) $m['v'];
$validated = $request->validate([
'title' => ['sometimes', 'string'],
'content' => ['sometimes', 'string'],
]);
$post->fill($validated);
$post->version = $expectedVersion;
try {
$post->save();
} catch (OptimisticLockingException $e) {
return response()->json(['message' => 'ETag obsolète'], 412);
}
return response()
->json($post)
->header('ETag', '"v' . $post->version . '"');
}
Côté client, transmets systématiquement l’ETag dans If-Match lors d’un PATCH/PUT:
async function patchPost(id, changes) {
const get = await fetch(`/api/posts/${id}`);
const etag = get.headers.get('ETag');
const current = await get.json();
const res = await fetch(`/api/posts/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'If-Match': etag, // "v123"
},
body: JSON.stringify(changes),
});
if (res.status === 412) {
// ETag obsolète: recharge et propose un merge
}
}
Checklist
Commence par relire attentivement le trait et l’exception pour confirmer que la condition WHERE version s’applique bien avant l’UPDATE et que la version est systématiquement incrémentée et poussée dans le set de colonnes à mettre à jour. Vérifie également que tes modèles concernés utilisent bien le trait et castent la version en entier.
Ensuite, exécute et teste toutes les commandes ainsi que les extraits de code dans un environnement de staging. Mets en place des tests automatisés qui couvrent au moins un cas de conflit et l’incrément de version, puis fais tourner ces tests dans ton pipeline CI.
Enfin, prépare la publication: ajoute la migration, déploie, exécute php artisan migrate, active l’exception handler 409 côté API, adapte tes formulaires pour inclure la version, et surveille les journaux pour détecter de potentiels conflits en production.
Conclusion
L’optimistic locking avec une colonne version apporte une sécurité simple et efficace contre les écrasements de données en concurrence. En l’intégrant sous forme de trait Eloquent, tu obtiens une solution réutilisable et explicite, compatible avec les patterns habituels (save, fill) et les contraintes d’un backend REST (409, ETag/If-Match). Ajoute quelques garde-fous pour les bulk updates, traite proprement les conflits côté UX/API et mets en place des retries raisonnés dans les jobs: tu réduiras drastiquement les corruptions silencieuses de données sans recourir à des verrous pessimistes coûteux.
Ressources
La documentation Eloquent de Laravel détaille le cycle de vie des modèles et le fonctionnement des mises à jour; elle constitue une base utile pour comprendre quand performUpdate est appelé: https://laravel.com/docs/10.x/eloquent
Les migrations et schémas de base de données dans Laravel sont décrits ici, pour les colonnes, index et déploiements: https://laravel.com/docs/10.x/migrations
Le helper retry et les patterns de gestion des erreurs dans Laravel aident à implémenter des retries simples avec des délais: https://laravel.com/docs/10.x/helpers#method-retry
Les en-têtes HTTP de préconditions (ETag, If-Match) sont standardisés dans la RFC 7232; c’est la référence pour intégrer un contrôle de version côté HTTP: https://www.rfc-editor.org/rfc/rfc7232
La fonctionnalité de tests parallèles de Laravel permet de simuler des accès concurrents pour valider ton implémentation: https://laravel.com/docs/10.x/testing#parallel-testing