Siparişi kaydettiniz ancak sipariş kalemleri asla veritabanına ulaşmadı. Ya da belki stok sayacı düştü, ödeme ağ geçidi zaman aşımına uğradı ve şimdi müşterinin kartından para çekildi ama sipariş kaydı yok. Bu senaryolar uç durumlar (edge cases) gibi görünür, ta ki öyle olana kadar. Yoğun bir uygulamada bunlar günlük baş ağrılarına dönüşür.

Temel neden neredeyse her zaman aynıdır: ayrı, izole olaylar olarak ele alınan bir dizi veritabanı yazma işlemi. Biri başarısız olduğunda, diğerleri geride kalır. Çözüm bir veritabanı işlemidir (transaction) ve Laravel'de araç DB::transaction() metodudur.

Bir İşlem (Transaction) Aslında Ne Vaat Eder?

Bir veritabanı işlemi, birden fazla işlemi tek bir iş birimine bağlar. Veritabanı motoru, içerideki her şeyin ya kalıcı olarak işlenmesini (commit) ya da tamamen geri alınmasını (rollback) garanti eder. Orta yol yoktur.

Bir banka havalesini düşünün. Sistem bir hesaptan para çekmeli ve diğerine yatırmalıdır. Eğer para çekme işleminden sonra yatırma işlemi başarısız olursa, para öylece boşluğa kaybolmaz. Banka çekme işlemini geri alır. Bu geri alma işlemi "rollback"tir. Eğer her iki adım da başarılı olursa, transfer "commit" edilir, yani yeni bakiyeler kalıcı olarak kaydedilir.

Bu "ya hep ya hiç" davranışı verilerinizin tutarlı kalmasını sağlar. Bu olmadan, kısmi hatalar tablolarınızın arasına yarım yazılmış kayıtlar saçar ve bu kayıtlar, hiçbir otomatik süreç onları güvenli bir şekilde nasıl temizleyeceğini bilmediği için veritabanınızda çürür.

Laravel Bu Bağlantıyı Nasıl Kurar?

Saf SQL'de, bir işlemin askıda kalmaması için her olası hatayı yakalamayı unutmadan BEGIN, COMMIT ve ROLLBACK komutlarını kendiniz yazardınız. Laravel bu kalıplaşmış kodları (boilerplate) tek bir metodun içine sarar.

DB::transaction() metoduna bir closure gönderirsiniz. Laravel işlemi başlatır, kodunuzu çalıştırır ve eğer closure bir hata (exception) fırlatmadan tamamlanırsa, işlemi otomatik olarak commit eder. Eğer herhangi bir şey başarısız olursa, Laravel hatayı yakalar, her şeyi geri alır ve hata günlüğünüzün (logging) ve hata yönetiminizin beklendiği gibi çalışmaya devam etmesi için hatayı tekrar fırlatır.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

Eğer bir yabancı anahtar (foreign key) eksik olduğu için veya veritabanı bağlantısı koptuğu için ödeme ekleme işlemi başarısız olursa; sipariş, sipariş kalemleri ve stok değişikliklerinin tamamı geri alınır. Sebepsiz yere yok olan bir envanterle ve ödemesi yapılmamış, gönderim bekleyen bir siparişle baş başa kalmazsınız.

Bu Sizi Nerelerde Kurtarır?

Bazı iş akışları kesinlikle işlem güvenliğine (transactional safety) bağlıdır. Ödeme (checkout) örneği en bariz olanıdır, ancak bu desen her yerde karşımıza çıkar.

Kullanıcı kaydı. Bir kullanıcı satırı, ardından bir profil satırı ve ardından varsayılan ayarlar oluşturmak. Eğer bir doğrulama (validation) uç durumu nedeniyle profil ekleme işlemi başarısız olursa, profili olmayan bir kullanıcı "hayalet hesap" haline gelir. Her kullanıcının bir profili olduğunu varsayan herhangi bir sayfa çökecek veya bozuk bir arayüz gösterecektir.

Toplu içe aktarmalar. Elli kayıt ekleyen bir CSV yüklemesi, yirmi altıncı satırda hatalı bir tarih olduğu için yirmi beş kaydı geride bırakmamalıdır. Bir grubu (batch) bir işlem içine almak, tüm içe aktarma işleminin tek bir temiz birim olarak başarısız olmasını sağlar. Yönetici, hangi satırların kısmen sızdığını bulmak için saatlerini harcamak yerine, dosyayı düzeltip tekrar dener.

Envanter ve muhasebe. Bir tablo fiziksel bir kaynağı, diğeri ise parayı veya kredileri takip ettiği her an, bu ikisi birlikte hareket etmelidir. Bunları ayırmak, uyuşmayan denetimlere ve yanlış raporlara yol açar.

Manuel Yönetim Gerektiğinde

Closure yaklaşımı çoğu durumu kapsar, ancak bazen daha fazla kontrole ihtiyaç duyarsınız. Bir servis sınıfı içindeki karmaşık koşullu mantık veya çalışma zamanında (runtime) commit edilip edilmeyeceğine karar verme ihtiyacı, tek bir closure kullanımını hantal hissettirebilir. Bu anlarda işlemi kendiniz yönetebilirsiniz:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

catch bloğu içindeki sıraya dikkat edin: önce geri al (rollback), sonra fırlat (throw). Eğer geri almadan önce hata fırlatırsanız, işlem bağlantı üzerinde açık kalır. Bu durum satırları kilitleyebilir, diğer sorgularda kilitlenmeye (deadlock) neden olabilir veya bağlantı havuzunuzu (connection pool) tüketebilir. Manuel işlemler güçlüdür ancak temizlik yükünü size bırakır.

Yan Etkileri İşlemin Dışında Tutun

Bu, canlı ortamda (production) ekipleri en çok zorlayan kuraldır. Bir işlem yalnızca veritabanı işlemlerini geri alabilir. Bir e-postayı geri gönderemez, bulut depolamasından bir dosyayı silemez veya bir ödeme API'si aracılığıyla bir ücret iadesi yapamaz.

Eğer işlem closure'ı içine bir Mail::send() çağrısı koyarsanız ve veritabanı iki satır sonra geri alınırsa (rollback), o e-posta yine de müşterinin gelen kutusuna ulaşır. Alıcı artık sisteminizde var olmayan bir sipariş için bir fatura almış olur. Aynı durum Slack bildirimleri, S3'e dosya yüklemeleri veya webhook gönderimleri için de geçerlidir.

Doğru sıralama şöyledir:

  1. İşlemi tamamlayın ve ihtiyacınız olan tüm ID'leri veya sonuçları kaydedin.
  2. Ancak ondan sonra harici yan etkileri tetikleyin.

Örneğin, onay e-postasını işlemin (commit) içinde değil, sonrasında kuyruğa alın:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

Harici çağrıları işlemin dışında tutmak için ikinci bir neden daha var: zaman. Bir işlem (transaction) kilitler tutar ve bir veritabanı bağlantısını meşgul eder. Bir işlemin içindeyken Stripe API yanıtı için üç saniye beklemek, üç saniyelik gereksiz bir kilitlenme süresidir. İşlemi kısa ve hızlı tutun.

Bu Hata Neden Gizlenir

Tek kullanıcılı ve yerel veritabanına sahip bir geliştirme makinesinde, ilgili eklemeler (inserts) neredeyse her zaman başarılı olur. Ağ stabildir, disk asla dolmaz ve rakip bir yük yoktur. Kod doğru görünür çünkü genellikle çalışır.

Canlı ortam (Production) farklıdır. İki müşteri tam olarak aynı milisaniyede sipariş verir. Bir kuyruk işleyicisi (queue worker), bir dağıtım (deployment) sırasında işin ortasında yeniden başlar. Bir ödeme sağlayıcısı otuz saniye boyunca zaman aşımına uğrar. İşlemler (transactions) olmadan, bu olaylar izlenmesi sancılı olan yetim kayıtlar (orphan records) ve uyuşmayan toplamlar oluşturur. En kötüsü ise, standart özellik testlerinin (feature tests) bunları nadiren yakalamasıdır; çünkü hata modu basit mantık hatalarına değil, zamanlamaya ve altyapıya bağlıdır.

Çözüm egzotik değildir. Mekaniktir. İki veya daha fazla ilgili yazma işlemi gördüğünüzde, bunları sarmalarsınız (wrap). Zamanla bu, bir isteği doğrulamak kadar otomatik hale gelmelidir.

Bunu Bir Refleks Haline Getirin

DB::transaction() karmaşıklık eklemez. Aksine, olay gerçekleştikten sonra kısmi verileri temizlemeye çalışmanın getirdiği gizli karmaşıklığı ortadan kaldırır. Eğer bir grup veritabanı işlemi birbirine bağlıysa, en başından itibaren onlara öyle davranın. Uygulamanız yük altında tutarlı kalacak, hata günlükleriniz temiz kalacak ve veritabanınız yarım kalmış kayıtların mezarlığına dönüşmeyecektir.