27.01.2026

Laravel’de Idempotent API Tasarımı: Tekrar Eden İsteklerde Güvenli İşlem

Ödeme, sipariş ve kayıt akışlarında tekrar eden istekleri idempotency key ile güvenli yönet.

Mobil ağlar, kullanıcı çift tıklamaları veya retry mekanizmaları yüzünden aynı API isteği birden fazla kez gelebilir. “İstek iki kez geldiyse iki kez işlem yapma” garantisi özellikle ödeme, sipariş oluşturma, stok düşme, e-posta tetikleme gibi yan etkili operasyonlarda kritiktir. Bu yazıda Laravel’de idempotent bir endpoint tasarlayıp uygulayacağız.

Neden idempotency?

  • Kullanıcı “Öde” butonuna iki kere basar → iki sipariş oluşmasın.
  • Client timeout alır, otomatik retry yapar → işlem tekrarlanmasın.
  • Queue/worker yeniden dener → yan etkiler katlanmasın.

HTTP tarafında GET doğal olarak idempotenttir; ama POST/PUT gibi isteklerde bunu bizim sağlamamız gerekir.

Yaklaşım: Idempotency-Key ile sonuç cache’leme

Client her kritik istekte bir Idempotency-Key (UUID) gönderir. Sunucu:

  1. Bu anahtarı kullanıcı/tenant bazında saklar
  2. Aynı anahtar tekrar gelirse ilk üretilen cevabı aynen döner
  3. İşlem devam ediyorsa “in progress” yaklaşımı ile çakışmayı engeller

1) Tablo: idempotency_keys

Basit bir tablo yeterli:

// database/migrations/xxxx_xx_xx_create_idempotency_keys_table.php
Schema::create('idempotency_keys', function ($table) {
    $table->id();
    $table->foreignId('user_id')->nullable()->index();
    $table->string('key', 80);
    $table->string('request_hash', 64);
    $table->string('status', 20)->default('started'); // started|completed
    $table->unsignedSmallInteger('response_status')->nullable();
    $table->json('response_headers')->nullable();
    $table->longText('response_body')->nullable();
    $table->timestamp('expires_at')->index();
    $table->timestamps();

    $table->unique(['user_id', 'key']);
});

request_hash: Aynı anahtarın farklı payload ile kullanılmasını yakalamak için.

2) Middleware: aynı isteğe aynı cevap

// app/Http/Middleware/IdempotencyMiddleware.php
namespace App\Http\Middleware;

use App\Models\IdempotencyKey;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class IdempotencyMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $key = $request->header('Idempotency-Key');
        if (!$key) {
            return $next($request); // zorunlu kılmak isterseniz 400 dönebilirsiniz
        }

        $userId = optional($request->user())->id;
        $hash = hash('sha256', $request->method().'|'.$request->path().'|'.json_encode($request->all()));

        return DB::transaction(function () use ($request, $next, $key, $userId, $hash) {
            $record = IdempotencyKey::where('user_id', $userId)
                ->where('key', $key)
                ->lockForUpdate()
                ->first();

            // Daha önce tamamlandıysa aynı cevabı dön
            if ($record && $record->status === 'completed' && $record->expires_at->isFuture()) {
                if ($record->request_hash !== $hash) {
                    return response()->json([
                        'message' => 'Idempotency-Key already used with different request payload.'
                    ], 409);
                }

                return response($record->response_body, $record->response_status)
                    ->withHeaders($record->response_headers ?? []);
            }

            // İlk kez geliyorsa kaydı başlat
            if (!$record) {
                $record = IdempotencyKey::create([
                    'user_id' => $userId,
                    'key' => $key,
                    'request_hash' => $hash,
                    'status' => 'started',
                    'expires_at' => now()->addHours(24),
                ]);
            } else {
                // started ve kilit bizde: bu istek muhtemelen paralel geldi.
                // İsterseniz 409/425 dönüp client'ın retry yapmasını sağlayabilirsiniz.
                if ($record->request_hash !== $hash) {
                    return response()->json([
                        'message' => 'Idempotency-Key in progress with different payload.'
                    ], 409);
                }
            }

            $response = $next($request);

            // Cevabı sakla
            $record->update([
                'status' => 'completed',
                'response_status' => $response->getStatusCode(),
                'response_headers' => $response->headers->all(),
                'response_body' => $response->getContent(),
            ]);

            return $response;
        });
    }
}

Model:

// app/Models/IdempotencyKey.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class IdempotencyKey extends Model
{
    protected $fillable = [
        'user_id','key','request_hash','status','response_status','response_headers','response_body','expires_at'
    ];

    protected $casts = [
        'response_headers' => 'array',
        'expires_at' => 'datetime',
    ];
}

3) Route’a uygulama

Sadece kritik endpoint’lere ekleyin:

Route::middleware(['auth:sanctum', 'idempotency'])->post('/orders', [OrderController::class, 'store']);

Kernel.php veya bootstrap/app.php (Laravel 11) üzerinden middleware alias’ını tanımlamayı unutmayın.

İnce noktalar

  • Scope: user_id + key iyi bir varsayılan. Multi-tenant yapıda tenant_id + user_id + key düşünebilirsiniz.
  • TTL (expires_at): Sonsuza kadar saklamak yerine 24 saat/7 gün gibi bir pencere belirleyin.
  • Yan etki sırası: En kritik olan (ör. ödeme) işleminden önce idempotency kaydını “started” olarak açmak, yarış durumlarını azaltır.
  • 409 vs aynı cevap: Bazı ekipler “in progress” durumda 409/425 döndürür. Diğerleri aynı cevabı bekletir. Burada ihtiyaçlarınıza göre karar verin.

Kısa sonuç

Idempotency, “retry olursa da sistem aynı kalır” garantisi verir. Laravel’de küçük bir tablo + middleware ile kritik POST akışlarını güvene alabilir, hem kullanıcı deneyimini hem de finansal/operasyonel tutarlılığı ciddi biçimde iyileştirebilirsiniz.