Pada API yang menerima PUT atau PATCH, masalah lost update muncul saat dua klien membaca resource yang sama, lalu sama-sama mengirim perubahan berdasarkan data lama. Request yang datang terakhir akan menimpa perubahan sebelumnya tanpa sadar. Di Laravel API, pendekatan yang praktis untuk mencegah ini adalah optimistic concurrency control memakai ETag dan header If-Match.

Intinya sederhana: server mengirim ETag yang mewakili versi resource saat ini. Saat klien ingin mengubah resource, klien wajib mengirim kembali ETag tersebut lewat header If-Match. Jika versi di server sudah berubah, request ditolak dengan 412 Precondition Failed. Jika header prasyarat wajib tetapi tidak dikirim, balas dengan 428 Precondition Required. Pendekatan ini cocok ketika konflik jarang terjadi dan Anda tidak ingin memakai lock yang menahan resource.

Mengapa lost update terjadi pada PUT/PATCH

Misalkan ada resource /api/posts/10.

  1. Klien A mengambil data post, mendapatkan judul lama dan ETag "v5".
  2. Klien B mengambil data yang sama, juga mendapatkan ETag "v5".
  3. Klien A mengubah judul dan mengirim If-Match: "v5". Update berhasil, versi menjadi "v6".
  4. Klien B, yang masih memegang data lama, mengirim perubahan lain dengan If-Match: "v5".

Tanpa validasi versi, perubahan dari B bisa menimpa perubahan A. Dengan If-Match, server melihat bahwa ETag sekarang sudah "v6", bukan "v5", lalu menolak request B dengan 412.

Ini bukan soal idempotensi

Sering terjadi kebingungan antara idempotensi dan concurrency control.

  • Idempotensi berarti request yang sama diulang beberapa kali tetap menghasilkan efek akhir yang sama. Contoh: PUT dengan payload identik ke resource yang sama.
  • Optimistic concurrency control memastikan update hanya diterapkan jika resource belum berubah sejak terakhir dibaca klien.

Jadi, request bisa saja idempoten tetapi tetap menyebabkan lost update jika tidak ada validasi versi. Masalahnya bukan pengulangan request, melainkan update berdasarkan state yang sudah basi.

Kapan ETag dan If-Match lebih tepat daripada lock

Pendekatan ini lebih tepat jika:

  • Konflik update relatif jarang.
  • API bersifat stateless dan melayani banyak klien.
  • Anda tidak ingin memegang database row lock atau distributed lock selama user berpikir atau mengedit form.
  • Latensi antar klien bervariasi dan durasi edit tidak bisa diprediksi.

Lock lebih tepat jika:

  • Konflik sangat sering dan biaya retry tinggi.
  • Operasi benar-benar harus eksklusif selama periode tertentu.
  • Update melibatkan proses bisnis kritis yang sulit di-merge atau diulang.

Catatan: lock bukan solusi default untuk API CRUD biasa. Menahan lock selama user mengedit di browser biasanya tidak realistis, rawan timeout, dan membuat sistem lebih rapuh.

Desain kontrak API Laravel API: ETag dan If-Match

Respons GET

Saat klien mengambil resource, kirim representasi resource beserta ETag.

GET /api/posts/10 HTTP/1.1
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2024-01-15T10:20:30.000000Z"
Cache-Control: private, no-cache

{
  "data": {
    "id": 10,
    "title": "Judul artikel",
    "body": "Isi artikel",
    "updated_at": "2024-01-15T10:20:30.000000Z"
  }
}

ETag bisa berasal dari updated_at, version, atau hash representasi tertentu. Untuk kebutuhan concurrency, yang penting adalah ETag berubah setiap kali state yang relevan berubah.

Request PUT/PATCH yang valid

PATCH /api/posts/10 HTTP/1.1
Content-Type: application/json
If-Match: "2024-01-15T10:20:30.000000Z"

{
  "title": "Judul baru"
}
HTTP/1.1 200 OK
Content-Type: application/json
ETag: "2024-01-15T10:25:11.000000Z"

{
  "data": {
    "id": 10,
    "title": "Judul baru",
    "body": "Isi artikel",
    "updated_at": "2024-01-15T10:25:11.000000Z"
  }
}

Jika versi sudah berubah: 412 Precondition Failed

HTTP/1.1 412 Precondition Failed
Content-Type: application/json
ETag: "2024-01-15T10:25:11.000000Z"

{
  "message": "Resource telah berubah sejak terakhir dibaca.",
  "code": "precondition_failed",
  "current_etag": "2024-01-15T10:25:11.000000Z"
}

Jika If-Match wajib tetapi tidak dikirim: 428 Precondition Required

HTTP/1.1 428 Precondition Required
Content-Type: application/json

{
  "message": "Header If-Match wajib untuk update resource ini.",
  "code": "precondition_required"
}

Status 428 berguna jika Anda ingin memaksa semua klien update memakai kontrol konkurensi, bukan hanya klien yang disiplin.

Memilih strategi ETag: updated_at vs version column

Opsi 1: ETag dari updated_at

Ini paling sederhana jika setiap perubahan model memang selalu memperbarui updated_at.

Kelebihan:

  • Tidak perlu menambah kolom baru.
  • Mudah dipahami dan cepat diterapkan.

Kekurangan:

  • Bergantung pada ketepatan perubahan timestamp.
  • Kurang ideal jika ada update yang tidak menyentuh updated_at.
  • Bisa membingungkan jika ada proses lain yang mengubah updated_at meski field bisnis tidak berubah.

Opsi 2: ETag dari version column

Tambahkan kolom integer version yang naik setiap update berhasil.

Kelebihan:

  • Lebih eksplisit sebagai mekanisme concurrency.
  • Tidak bergantung pada format timestamp.
  • Mudah dipakai dalam query atomik WHERE id = ? AND version = ?.

Kekurangan:

  • Perlu migrasi dan pengelolaan increment versi.
  • Butuh disiplin agar semua jalur update ikut menaikkan versi.

Jika API Anda cukup sederhana, updated_at sering cukup. Jika ingin kontrak yang lebih tegas dan tahan terhadap variasi perilaku update, gunakan version.

Implementasi di Laravel dengan updated_at

Membuat helper ETag pada model

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['title', 'body'];

    public function etag(): string
    {
        return '"' . optional($this->updated_at)?->toJSON() . '"';
    }
}

ETag sengaja diberi tanda kutip agar sesuai format umum header ETag. Untuk concurrency, gunakan strong validator, jadi hindari awalan W/.

Controller GET dan PATCH

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class PostController extends Controller
{
    public function show(Post $post): JsonResponse
    {
        return response()
            ->json([
                'data' => [
                    'id' => $post->id,
                    'title' => $post->title,
                    'body' => $post->body,
                    'updated_at' => $post->updated_at?->toJSON(),
                ],
            ], Response::HTTP_OK)
            ->header('ETag', $post->etag())
            ->header('Cache-Control', 'private, no-cache');
    }

    public function update(Request $request, Post $post): JsonResponse
    {
        $ifMatch = $request->header('If-Match');

        if (!$ifMatch) {
            return response()->json([
                'message' => 'Header If-Match wajib untuk update resource ini.',
                'code' => 'precondition_required',
            ], Response::HTTP_PRECONDITION_REQUIRED);
        }

        $currentEtag = $post->etag();

        if ($ifMatch !== $currentEtag && $ifMatch !== '*') {
            return response()
                ->json([
                    'message' => 'Resource telah berubah sejak terakhir dibaca.',
                    'code' => 'precondition_failed',
                    'current_etag' => $currentEtag,
                ], Response::HTTP_PRECONDITION_FAILED)
                ->header('ETag', $currentEtag);
        }

        $validated = $request->validate([
            'title' => ['sometimes', 'string', 'max:255'],
            'body' => ['sometimes', 'string'],
        ]);

        $post->fill($validated);
        $post->save();
        $post->refresh();

        return response()
            ->json([
                'data' => [
                    'id' => $post->id,
                    'title' => $post->title,
                    'body' => $post->body,
                    'updated_at' => $post->updated_at?->toJSON(),
                ],
            ], Response::HTTP_OK)
            ->header('ETag', $post->etag());
    }
}

Pola di atas cukup untuk banyak kasus, tetapi ada celah kecil antara pengecekan ETag dan operasi save(). Jika dua request lolos hampir bersamaan, keduanya masih bisa menulis. Untuk menutup celah ini, gunakan query atomik berbasis kondisi di database. Itulah alasan version column sering lebih kuat.

Implementasi yang lebih kuat dengan version column

Skema migrasi

<?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(1)->after('body');
        });
    }

    public function down(): void
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('version');
        });
    }
};

Model dengan ETag dari version

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    protected $fillable = ['title', 'body'];

    public function etag(): string
    {
        return '"' . $this->version . '"';
    }
}

Update atomik berdasarkan version

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class PostController extends Controller
{
    public function show(Post $post): JsonResponse
    {
        return response()
            ->json([
                'data' => [
                    'id' => $post->id,
                    'title' => $post->title,
                    'body' => $post->body,
                    'version' => $post->version,
                ],
            ], Response::HTTP_OK)
            ->header('ETag', $post->etag())
            ->header('Cache-Control', 'private, no-cache');
    }

    public function update(Request $request, Post $post): JsonResponse
    {
        $ifMatch = $request->header('If-Match');

        if (!$ifMatch) {
            return response()->json([
                'message' => 'Header If-Match wajib untuk update resource ini.',
                'code' => 'precondition_required',
            ], Response::HTTP_PRECONDITION_REQUIRED);
        }

        if (!preg_match('/^"(.+)"$/', $ifMatch, $matches) && $ifMatch !== '*') {
            return response()->json([
                'message' => 'Format If-Match tidak valid.',
                'code' => 'invalid_if_match',
            ], Response::HTTP_BAD_REQUEST);
        }

        $validated = $request->validate([
            'title' => ['sometimes', 'string', 'max:255'],
            'body' => ['sometimes', 'string'],
        ]);

        $expectedVersion = $ifMatch === '*' ? null : (int) $matches[1];

        $query = Post::query()->whereKey($post->id);

        if ($expectedVersion !== null) {
            $query->where('version', $expectedVersion);
        }

        $affected = $query->update(array_merge(
            $validated,
            [
                'version' => $expectedVersion !== null ? $expectedVersion + 1 : $post->version + 1,
                'updated_at' => now(),
            ]
        ));

        if ($affected === 0) {
            $fresh = Post::findOrFail($post->id);

            return response()
                ->json([
                    'message' => 'Resource telah berubah sejak terakhir dibaca.',
                    'code' => 'precondition_failed',
                    'current_etag' => $fresh->etag(),
                ], Response::HTTP_PRECONDITION_FAILED)
                ->header('ETag', $fresh->etag());
        }

        $fresh = Post::findOrFail($post->id);

        return response()
            ->json([
                'data' => [
                    'id' => $fresh->id,
                    'title' => $fresh->title,
                    'body' => $fresh->body,
                    'version' => $fresh->version,
                ],
            ], Response::HTTP_OK)
            ->header('ETag', $fresh->etag());
    }
}

Poin pentingnya ada pada query WHERE id = ? AND version = ?. Hanya satu request yang bisa cocok dengan versi lama. Request berikutnya akan menghasilkan affected = 0 dan dibalas 412. Ini lebih aman daripada sekadar cek lalu save di memori aplikasi.

Memindahkan validasi If-Match ke middleware

Agar controller lebih rapi, Anda bisa memindahkan kewajiban header If-Match ke middleware. Middleware ini cocok untuk route update tertentu.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class RequireIfMatch
{
    public function handle(Request $request, Closure $next): Response
    {
        if (in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true)
            && !$request->hasHeader('If-Match')) {
            return response()->json([
                'message' => 'Header If-Match wajib untuk operasi ini.',
                'code' => 'precondition_required',
            ], Response::HTTP_PRECONDITION_REQUIRED);
        }

        return $next($request);
    }
}

Daftarkan middleware, lalu terapkan ke route yang memang ingin diproteksi. Dengan begitu, kontrak API lebih konsisten dan tidak bergantung pada implementasi tiap controller.

Route::middleware(['require.if-match'])
    ->patch('/posts/{post}', [PostController::class, 'update']);

Feature test Laravel untuk skenario sukses dan konflik

Pengujian feature penting karena masalah konkurensi sering tampak benar di level kode, tetapi kontraknya salah di level HTTP.

<?php

namespace Tests\Feature;

use App\Models\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PostConcurrencyTest extends TestCase
{
    use RefreshDatabase;

    public function test_show_returns_etag(): void
    {
        $post = Post::factory()->create(['version' => 1]);

        $response = $this->getJson("/api/posts/{$post->id}");

        $response
            ->assertOk()
            ->assertHeader('ETag', '"1"');
    }

    public function test_update_requires_if_match(): void
    {
        $post = Post::factory()->create(['version' => 1]);

        $response = $this->patchJson("/api/posts/{$post->id}", [
            'title' => 'Baru',
        ]);

        $response
            ->assertStatus(428)
            ->assertJsonPath('code', 'precondition_required');
    }

    public function test_update_succeeds_when_etag_matches(): void
    {
        $post = Post::factory()->create([
            'title' => 'Lama',
            'version' => 1,
        ]);

        $response = $this
            ->withHeaders(['If-Match' => '"1"'])
            ->patchJson("/api/posts/{$post->id}", [
                'title' => 'Baru',
            ]);

        $response
            ->assertOk()
            ->assertHeader('ETag', '"2"')
            ->assertJsonPath('data.title', 'Baru')
            ->assertJsonPath('data.version', 2);
    }

    public function test_update_fails_when_etag_is_stale(): void
    {
        $post = Post::factory()->create([
            'title' => 'Awal',
            'version' => 2,
        ]);

        $response = $this
            ->withHeaders(['If-Match' => '"1"'])
            ->patchJson("/api/posts/{$post->id}", [
                'title' => 'Tertimpa',
            ]);

        $response
            ->assertStatus(412)
            ->assertHeader('ETag', '"2"')
            ->assertJsonPath('code', 'precondition_failed');
    }
}

Jika Anda memakai strategi updated_at, sesuaikan assertion ETag agar membandingkan nilai timestamp yang benar. Dalam test, strategi version biasanya lebih mudah dan stabil.

Edge case cache, proxy, dan perilaku HTTP

1. Jangan campuradukkan tujuan caching dan concurrency

ETag sering dipakai untuk caching lewat If-None-Match, sedangkan di sini kita fokus pada update bersyarat lewat If-Match. Nilai ETag boleh sama, tetapi semantiknya berbeda:

  • If-None-Match umum dipakai untuk GET agar server bisa mengembalikan 304.
  • If-Match dipakai untuk memastikan update hanya berlaku pada versi tertentu.

Jangan menganggap dukungan GET conditional otomatis berarti update Anda aman dari lost update.

2. Proxy atau CDN bisa mengubah perilaku cache

Jika resource bersifat privat atau sering berubah per pengguna, set header cache dengan hati-hati. Untuk endpoint detail resource yang dipakai editor, Cache-Control: private, no-cache sering lebih aman. Tujuannya bukan melarang cache sepenuhnya, tetapi memaksa revalidasi dan mengurangi risiko klien bekerja dari representasi yang terlalu lama.

Pastikan juga proxy tidak menghapus header If-Match atau ETag. Dalam sistem dengan API gateway, lakukan verifikasi di log request/response aktual.

3. ETag harus mewakili state yang benar-benar relevan

Jika ETag dihitung dari JSON response penuh, perubahan urutan field, transformasi serializer, atau penambahan field non-bisnis bisa memicu konflik palsu. Karena itu, untuk concurrency lebih baik memakai versi eksplisit atau penanda state yang stabil.

4. Hati-hati dengan If-Match: *

Secara HTTP, If-Match: * berarti operasi boleh dilakukan jika resource ada, tanpa mencocokkan versi spesifik. Ini berguna untuk beberapa kasus, tetapi tidak mencegah lost update karena Anda tidak memverifikasi state yang dibaca klien. Jika tujuan Anda murni mencegah overwrite, pertimbangkan untuk menolak wildcard ini.

Kesalahan umum saat implementasi

  • Hanya mengecek ETag di controller, lalu save biasa. Ini masih punya celah race kecil. Jika konflik benar-benar penting, gunakan update atomik di database.
  • Menghasilkan ETag dari representasi yang tidak stabil. Misalnya hash JSON yang berubah karena formatting atau field tambahan.
  • Tidak mengirim ETag terbaru setelah update sukses. Klien jadi tetap memegang versi lama.
  • Menganggap PUT idempoten berarti aman dari race condition. Idempotensi tidak menyelesaikan konflik update antar klien.
  • Melupakan jalur update lain. Misalnya job internal, admin panel, atau command yang mengubah data tanpa menaikkan version.

Checklist integrasi untuk klien

  1. Lakukan GET resource dan simpan nilai header ETag.
  2. Saat mengirim PUT/PATCH, sertakan If-Match dengan nilai ETag yang terakhir diterima.
  3. Jika menerima 200 OK, ganti ETag lokal dengan ETag baru dari respons.
  4. Jika menerima 412 Precondition Failed, ambil ulang resource terbaru, tampilkan konflik ke user, lalu minta user mengulang atau merge perubahan.
  5. Jika menerima 428 Precondition Required, perbaiki integrasi klien karena update tanpa If-Match tidak diperbolehkan.
  6. Jangan membuat asumsi bahwa data lokal selalu mutakhir hanya karena baru saja diambil beberapa detik lalu.

Rekomendasi praktis

Jika Anda ingin implementasi cepat, gunakan ETag dari updated_at dan paksa If-Match pada endpoint update. Jika Anda ingin kontrol yang lebih kuat dan mudah diuji, tambahkan version column lalu lakukan update atomik dengan kondisi versi.

Untuk banyak Laravel API, kombinasi berikut paling praktis:

  • GET selalu mengirim ETag.
  • PUT/PATCH wajib mengirim If-Match.
  • Jika versi tidak cocok, balas 412.
  • Jika header tidak dikirim, balas 428.
  • Gunakan version column bila risiko race tidak boleh lolos.

Dengan kontrak ini, Anda tidak menghilangkan semua konflik, tetapi konflik menjadi terdeteksi dan eksplisit, bukan diam-diam menimpa data. Itulah tujuan utama optimistic concurrency control di API.