Bayangkan skenario ini: seorang pengguna menekan tombol "Bayar" di aplikasi e-commerce. Request berhasil mencapai server, order tersimpan, kartu kredit terdebet — tapi respons HTTP gagal kembali ke browser karena koneksi timeout. Pengguna melihat spinner yang tidak kunjung berhenti, lalu menekan tombol bayar sekali lagi. Tanpa perlindungan khusus, ia baru saja membayar produk yang sama dua kali.
Kasus seperti ini adalah alasan utama mengapa idempotency menjadi syarat wajib untuk operasi finansial. Dengan MongoDB di Laravel, kita bisa menjamin bahwa retry dari sisi client — baik karena timeout, network blip, atau pengguna yang tidak sabar — tetap menghasilkan satu transaksi, bukan dua. Artikel ini membahas pola implementasinya secara praktis, termasuk bagaimana cara scoping key yang benar agar pengguna tetap bisa membeli produk yang sama di waktu berbeda.
Memahami Maksud Pengguna vs Jumlah Request
Kesalahan paling umum saat pertama kali menerapkan idempotency adalah menganggap "request yang sama = operasi yang sama". Padahal, seorang pengguna bisa saja benar-benar ingin membeli produk yang sama dua kali di hari berbeda, dan sistem harus membedakan niat itu dari retry yang tidak disengaja.
Aturan mainnya sederhana: idempotency harus mencerminkan satu intent bisnis, bukan satu request HTTP. Saat pengguna menekan tombol checkout, itulah satu intent. Semua retry yang mengikuti intent tersebut — entah karena timeout atau klik ganda — harus menghasilkan order yang sama. Tapi saat pengguna kembali besok dan melakukan checkout baru, itu adalah intent yang berbeda dan harus menghasilkan order baru.
Cara mewujudkannya adalah dengan menggabungkan user_id dan idempotency_key sebagai kunci unik, bukan hanya idempotency_key saja. Key sendiri di-generate di sisi client sekali per intent, lalu dikirim ulang pada setiap retry.
Generate idempotency_key saat pengguna menekan tombol checkout, bukan di dalam fungsi yang memanggil API. Kalau di-generate per panggilan, setiap retry akan punya key berbeda dan perlindungannya hilang.
Menyiapkan Model Order dengan Compound Index
Asumsi kita pakai package mongodb/laravel-mongodb untuk integrasi MongoDB di Laravel 13. Pertama, tambahkan field idempotency_key ke model order. Dalam contoh ini kita pakai skenario aplikasi langganan kursus online, bukan e-commerce umum, agar lebih konkret.
Berikut definisi model untuk enrollment kursus:
// app/Models/Enrollment.php
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class Enrollment extends Model
{
protected $connection = 'mongodb';
protected $collection = 'enrollments';
protected $fillable = [
'learner_id',
'course_id',
'amount_cents',
'currency',
'idempotency_key',
'payment_status',
'paid_at',
];
protected $casts = [
'amount_cents' => 'integer',
'paid_at' => 'datetime',
];
}
Model ini merepresentasikan satu transaksi pembelian akses kursus. Field idempotency_key inilah yang akan jadi kunci perlindungan terhadap duplikasi.
Selanjutnya, buat compound unique index pada kombinasi learner_id dan idempotency_key. Index ini yang menjamin MongoDB akan menolak dokumen kedua dengan pasangan yang sama, bahkan di bawah kondisi race.
// database/migrations/2026_04_09_create_enrollments_index.php
use Illuminate\Database\Migrations\Migration;
use MongoDB\Laravel\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::connection('mongodb')->table('enrollments', function (Blueprint $collection) {
$collection->index(
['learner_id' => 1, 'idempotency_key' => 1],
'unique_learner_idempotency',
null,
['unique' => true]
);
});
}
public function down(): void
{
Schema::connection('mongodb')->table('enrollments', function (Blueprint $collection) {
$collection->dropIndex('unique_learner_idempotency');
});
}
};
Perhatikan bahwa index ini compound, bukan hanya pada idempotency_key saja. Dengan begini, dua pengguna berbeda yang kebetulan meng-generate UUID yang sama (secara teoretis mungkin) tidak akan saling tabrak. Lebih penting lagi, pengguna yang sama tetap bisa membuat enrollment kursus lain di masa depan selama key yang dikirim berbeda.
Membuat Action untuk Proses Enrollment
Di Laravel 13 dengan pendekatan Service Action Pattern, logic pembelian ditempatkan di class action yang terpisah dari controller. Action ini yang memegang kontrak idempotency dan memanggil payment gateway.
// app/Actions/Enrollment/EnrollLearnerAction.php
namespace App\Actions\Enrollment;
use App\Models\Enrollment;
use App\Services\PaymentGateway;
use Illuminate\Support\Facades\DB;
use MongoDB\Driver\Exception\BulkWriteException;
class EnrollLearnerAction
{
public function __construct(private PaymentGateway $gateway) {}
public function execute(
string $learnerId,
string $courseId,
int $amountCents,
string $idempotencyKey,
): Enrollment {
$existing = Enrollment::where('learner_id', $learnerId)
->where('idempotency_key', $idempotencyKey)
->first();
if ($existing) {
return $existing;
}
return DB::connection('mongodb')->transaction(function () use (
$learnerId,
$courseId,
$amountCents,
$idempotencyKey,
) {
try {
$enrollment = Enrollment::create([
'learner_id' => $learnerId,
'course_id' => $courseId,
'amount_cents' => $amountCents,
'currency' => 'IDR',
'idempotency_key' => $idempotencyKey,
'payment_status' => 'pending',
]);
} catch (BulkWriteException $e) {
if ($this->isDuplicateKeyError($e)) {
return Enrollment::where('learner_id', $learnerId)
->where('idempotency_key', $idempotencyKey)
->firstOrFail();
}
throw $e;
}
$charge = $this->gateway->charge($learnerId, $amountCents, $idempotencyKey);
$enrollment->update([
'payment_status' => 'paid',
'paid_at' => now(),
]);
return $enrollment->fresh();
});
}
private function isDuplicateKeyError(BulkWriteException $e): bool
{
return $e->getCode() === 11000;
}
}
Ada dua lapis perlindungan di sini. Lapis pertama adalah query $existing di awal — ini menangani kasus umum di mana request kedua datang setelah request pertama selesai. Lapis kedua adalah try/catch pada BulkWriteException dengan kode 11000, yaitu error MongoDB untuk duplicate key. Lapisan ini krusial untuk kasus race condition: dua request bersamaan yang sama-sama lolos pengecekan awal, lalu salah satunya kalah saat insert karena index unik menolaknya.
Jangan andalkan hanya pengecekan $existing di awal. Tanpa compound unique index dan penanganan BulkWriteException, dua request yang datang bersamaan masih bisa menyebabkan double charge. Index dan exception handler adalah pengaman terakhir, bukan opsional.
Memanggil Action dari Controller
Controller bertugas menerima HTTP request, memvalidasi, lalu mendelegasikan ke action. Tidak ada logic bisnis di sini.
// app/Http/Controllers/EnrollmentController.php
namespace App\Http\Controllers;
use App\Actions\Enrollment\EnrollLearnerAction;
use App\Http\Requests\StoreEnrollmentRequest;
use Illuminate\Http\JsonResponse;
class EnrollmentController extends Controller
{
public function store(
StoreEnrollmentRequest $request,
EnrollLearnerAction $action,
): JsonResponse {
$enrollment = $action->execute(
learnerId: $request->user()->id,
courseId: $request->validated('course_id'),
amountCents: $request->validated('amount_cents'),
idempotencyKey: $request->validated('idempotency_key'),
);
return response()->json($enrollment, 201);
}
}
Form request StoreEnrollmentRequest memastikan idempotency_key selalu ada dan berbentuk UUID valid. Ini penting — tanpa validasi ketat, client yang nakal bisa mengirim string kosong dan membypass perlindungan.
// app/Http/Requests/StoreEnrollmentRequest.php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEnrollmentRequest extends FormRequest
{
public function rules(): array
{
return [
'course_id' => ['required', 'string'],
'amount_cents' => ['required', 'integer', 'min:1'],
'idempotency_key' => ['required', 'uuid'],
];
}
}
Generate Key di Sisi Client
Sisi client punya tanggung jawab penting: meng-generate key sekali per intent, lalu memakai key yang sama untuk semua percobaan retry.
// resources/js/features/enrollment/useCheckout.ts
import { useRef } from 'react';
export function useCheckout(courseId: string, amountCents: number) {
const idempotencyKeyRef = useRef<string | null>(null);
async function submit() {
if (!idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}
const response = await fetch('/api/enrollments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
course_id: courseId,
amount_cents: amountCents,
idempotency_key: idempotencyKeyRef.current,
}),
});
if (!response.ok && response.status >= 500) {
throw new Error('Retry dengan key yang sama');
}
idempotencyKeyRef.current = null;
return response.json();
}
return { submit };
}
Kuncinya ada di useRef: key di-generate sekali dan disimpan di ref selama intent belum selesai. Hanya setelah response sukses (bukan 5xx), ref direset sehingga checkout berikutnya akan menghasilkan key baru. Error bisnis seperti "saldo tidak cukup" atau "kursus sudah penuh" harus menghasilkan key baru pada percobaan berikutnya — karena pengguna kemungkinan besar akan memperbaiki input, bukan sekadar retry.
| Jenis error | Aksi client | Key untuk retry |
|---|---|---|
| Network timeout | Retry otomatis | Key sama |
| HTTP 5xx | Retry otomatis | Key sama |
| HTTP 4xx (validation) | Tampilkan pesan, tunggu user | Key baru |
| Insufficient funds | Minta user ganti metode bayar | Key baru |
Menguji Perilaku Idempotency
Testing adalah satu-satunya cara untuk yakin bahwa perlindungan ini benar-benar bekerja. Minimal ada tiga skenario yang wajib ditulis sebagai feature test di Pest.
// tests/Feature/EnrollmentIdempotencyTest.php
use App\Models\Enrollment;
use App\Models\Learner;
use Illuminate\Support\Str;
it('returns same enrollment when same key is used twice', function () {
$learner = Learner::factory()->create();
$key = (string) Str::uuid();
$payload = [
'course_id' => 'course-laravel-13',
'amount_cents' => 250_000,
'idempotency_key' => $key,
];
$first = $this->actingAs($learner)->postJson('/api/enrollments', $payload);
$second = $this->actingAs($learner)->postJson('/api/enrollments', $payload);
expect($first->json('_id'))->toBe($second->json('_id'));
expect(Enrollment::where('learner_id', $learner->id)->count())->toBe(1);
});
it('creates separate enrollments for different keys', function () {
$learner = Learner::factory()->create();
$this->actingAs($learner)->postJson('/api/enrollments', [
'course_id' => 'course-laravel-13',
'amount_cents' => 250_000,
'idempotency_key' => (string) Str::uuid(),
]);
$this->actingAs($learner)->postJson('/api/enrollments', [
'course_id' => 'course-laravel-13',
'amount_cents' => 250_000,
'idempotency_key' => (string) Str::uuid(),
]);
expect(Enrollment::where('learner_id', $learner->id)->count())->toBe(2);
});
Tes pertama memastikan dua request dengan key identik menghasilkan satu enrollment. Tes kedua membuktikan bahwa pengguna yang sama bisa benar-benar membeli produk yang sama dua kali selama intent-nya berbeda — yaitu ketika key berbeda.
Untuk skenario concurrency, biasanya butuh integration test yang menjalankan dua request paralel. Ini bisa dilakukan dengan parallel testing Pest atau tool seperti k6. Yang jelas, tanpa compound unique index di MongoDB, tes concurrency akan gagal karena lapis pertama (pengecekan $existing) tidak cukup.
Perluas Pola ke Operasi Berisiko Lain
Idempotency bukan hanya untuk checkout. Pola yang sama berlaku untuk semua operasi di mana efek samping tidak boleh berganda:
- Refund atau pembatalan transaksi
- Penerbitan voucher atau kupon diskon
- Webhook inbound dari payment gateway (Midtrans, Xendit, Stripe)
- Transfer saldo antar akun internal
- Pengiriman notifikasi WhatsApp berbayar
Untuk webhook inbound khususnya, pattern ini sering wajib hukumnya karena provider seperti Stripe dan Midtrans memang mengirim ulang webhook jika endpoint kita tidak merespons dalam waktu tertentu. Tanpa idempotency, satu pembayaran bisa memicu email konfirmasi berulang kali.
Pola serupa untuk konsistensi lintas koleksi sudah pernah dibahas di panduan ACID transactions MongoDB di Laravel, yang bisa dipadukan dengan artikel ini untuk membangun lapisan data yang benar-benar tahan banting.
Kesimpulan
Idempotency bukan fitur mewah — untuk sistem yang menangani uang, ia adalah garis pertahanan antara kepuasan pelanggan dan keluhan massal di media sosial. Kombinasi compound unique index di MongoDB, pengecekan awal di action, dan penanganan BulkWriteException memberi tiga lapis perlindungan yang saling menutupi celah satu sama lain. Langkah berikutnya yang layak dieksplorasi adalah monitoring rate duplicate request untuk mendeteksi masalah UX atau konfigurasi timeout yang terlalu agresif jauh sebelum pengguna mengeluh.







