Laravel 13 min de lecture

Éviter les écrasements concurrents avec l’optimistic locking Eloquent

#api#concurrency#database#eloquent#http-409#laravel#optimistic-locking#queues

Implémente l’optimistic locking Eloquent (verrou optimiste) pour éviter les mises à jour concurrentes. Trait réutilisable, gestion du 409, retry et tests.

É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