Laravel Clean Architecture ve Service Layer Pratikleri
Laravel'in varsayılan yapısı küçük ve orta ölçekli projelerde hızlı sonuç verir. Ancak controller'lar şişmeye, iş kuralları Eloquent modellerine yayılmaya ve test yazmak zorlaşmaya başladığında, kodu katmanlara ayırmak kaçınılmaz hale gelir. Bu rehber, Laravel projelerinde Service Layer ve Clean Architecture yaklaşımını uygulanabilir biçimde ele alır.
Önkoşullar ve Kapsam
- Laravel 10 veya 11 (her ikisinde de bu rehberdeki yapı çalışır; sürüme özgü API farkları ayrıca belirtilir).
- PHP 8.1 ve üzeri.
- Composer ve temel Laravel bilgisi (routing, Eloquent, dependency injection).
- Test altyapısı olarak PHPUnit veya Pest.
Bu rehber belirli bir sürüm numarasına bağlı kalarak yazılmadı; katmanlama yaklaşımı Laravel'in uzun süredir koruduğu API'lere dayanır.
Neden Katmanlara Ayırıyoruz?
Laravel'de tipik bir controller zamanla şu sorumlulukları üstlenir:
- HTTP isteğini doğrulamak,
- İş kurallarını uygulamak,
- Veritabanına yazmak,
- Yan olayları tetiklemek (e-posta, kuyruk işi, harici servis çağrısı),
- Yanıtı biçimlendirmek.
Bu sorumlulukların tümü tek sınıfta toplandığında, aynı iş kuralı hem HTTP üzerinden hem de bir konsol komutundan veya kuyruk işinden çağrılmak istendiğinde kod tekrarlanır. Service Layer, iş kuralını HTTP'den bağımsız bir yere taşıyarak bu tekrarı ortadan kaldırır.
Clean Architecture ise daha geniş bir iddiaya sahiptir: iş kuralları framework'ten, veritabanından ve dış servislerden bağımsız olmalıdır. Laravel projelerinde bunu birebir uygulamak her zaman gerekmez; pragmatik bir orta yol genellikle daha verimlidir.
Katmanların Tanımı
Pratikte uygulanabilir bir düzen şöyle görünür:
- Controller / HTTP katmanı: İsteği alır, doğrular (Form Request ile), Service'i çağırır, yanıtı döner. İş kuralı içermez.
- Service katmanı: İş akışını yürütür. Transaction yönetimi, birden fazla modelin koordinasyonu, dış servis çağrıları burada yer alır.
- Repository katmanı (opsiyonel): Veri erişimini soyutlar. Eloquent zaten güçlü bir soyutlama sunduğu için her proje için gerekli değildir; yalnızca veri kaynağını değiştirme veya karmaşık sorguları izole etme ihtiyacı varsa ekleyin.
- Domain / Model katmanı: Eloquent modelleri, value object'ler ve iş kurallarının bir kısmı. Modelleri "sadece veri taşıyıcı" hâline getirmek yerine, kendi verisiyle ilgili basit kuralları modelde tutmak Laravel'in ruhuna daha yakındır.
Adım Adım Uygulama
1. Klasör Yapısını Belirleyin
app/ altında şu düzeni kurabilirsiniz:
app/
Actions/
Services/
Repositories/
DataTransferObjects/
Http/
Controllers/
Requests/
Tek bir doğru yapı yoktur. Önemli olan, ekibin aynı kuralları tutarlı biçimde uygulamasıdır.
2. Form Request ile Doğrulamayı Ayırın
Doğrulama, Service'in sorumluluğu değildir. HTTP girdisinin doğrulanması HTTP katmanına aittir:
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'customer_id' => ['required', 'integer', 'exists:customers,id'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}
Beklenen davranış: Geçersiz istek, controller'a ulaşmadan 422 yanıtıyla reddedilir.
3. Service Sınıfını Yazın
Service, iş akışını yürütür. Controller'ın bilmediği ayrıntıları (transaction, stok kontrolü, olay tetikleme) burada toplar:
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class OrderService
{
public function createOrder(int $customerId, array $items): Order
{
return DB::transaction(function () use ($customerId, $items) {
$order = Order::create([
'customer_id' => $customerId,
'status' => 'pending',
]);
foreach ($items as $item) {
$product = Product::lockForUpdate()->findOrFail($item['product_id']);
if ($product->stock < $item['quantity']) {
throw ValidationException::withMessages([
'items' => "Yetersiz stok: {$product->id}",
]);
}
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price' => $product->price,
]);
$product->decrement('stock', $item['quantity']);
}
return $order;
});
}
}
Buradaki kritik noktalar:
DB::transactioniş akışının tamamını kapsar; yarıda hata olursa geri alınır.lockForUpdate()eşzamanlı isteklerde stok tutarsızlığını önler.- Stok yetersizse
ValidationExceptionfırlatılır; Laravel bunu otomatik olarak 422 yanıtına çevirir.
Not:
lockForUpdateyalnızca transaction içinde anlamlıdır. Transaction dışında çağrılırsa kilit beklenen etkiyi göstermez.
4. Controller'ı İnce Tutun
namespace App\Http\Controllers;
use App\Http\Requests\StoreOrderRequest;
use App\Services\OrderService;
use Illuminate\Http\JsonResponse;
class OrderController extends Controller
{
public function __construct(private readonly OrderService $orderService)
{
}
public function store(StoreOrderRequest $request): JsonResponse
{
$order = $this->orderService->createOrder(
customerId: $request->integer('customer_id'),
items: $request->validated('items'),
);
return response()->json($order->load('items'), 201);
}
}
Controller artık yalnızca üç iş yapar: isteği alır, Service'i çağırır, yanıtı döner. Service, dependency injection ile otomatik olarak çözülür.
5. Transaction Sınırını Doğru Yere Koyun
Yaygın bir hata, transaction'ı controller'a koymaktır. Bu, iş kuralını HTTP katmanına sızdırır ve Service'i başka bir yerden çağırdığınızda transaction'ı unutmanıza yol açar. Transaction Service içinde kalmalıdır.
Birden fazla Service'in tek bir transaction altında çalışması gerekiyorsa, üst düzey bir "orchestrator" Service yazın ve transaction'ı orada açın. İç Service'ler kendi transaction'larını açmamalıdır; iç içe DB::transaction çağrıları Laravel'de savepoint kullanır ve genelde sorun çıkarmaz, ancak gereksiz karmaşıklık yaratır.
6. Repository Gerektiğinde Ekleyin
Eloquent zaten bir repository gibi davranır. Her model için repository yazmak, çoğu projede gereksiz soyutlama katmanı oluşturur. Repository'yi şu durumlarda düşünün:
- Aynı karmaşık sorgu birden fazla Service tarafından kullanılıyorsa,
- Veri kaynağı değişebiliyorsa (örneğin bir kısmı harici API'den geliyorsa),
- Sorguları izole edip test etmek istiyorsanız.
Basit bir repository örneği:
namespace App\Repositories;
use App\Models\Order;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class OrderRepository
{
public function paginateForCustomer(int $customerId, int $perPage = 20): LengthAwarePaginator
{
return Order::query()
->where('customer_id', $customerId)
->with('items')
->latest()
->paginate($perPage);
}
}
Service bu repository'yi enjekte ederek kullanır. Böylece sorgu, Service'in iş kuralıyla karışmaz.
7. DTO ile Katmanlar Arası Veri Taşıyın
Controller'dan Service'e dizi yerine DTO geçirmek, tip güvenliği ve okunabilirlik sağlar:
namespace App\DataTransferObjects;
class OrderItemData
{
public function __construct(
public readonly int $productId,
public readonly int $quantity,
) {
}
public static function fromArray(array $data): self
{
return new self(
productId: (int) $data['product_id'],
quantity: (int) $data['quantity'],
);
}
}
DTO'ları abartmayın; yalnızca katmanlar arası sınırı netleştirdiği yerlerde kullanın.
Yaygın Hatalar
- Anemik modeller: Tüm iş kuralını Service'e taşıyıp modeli yalnızca veri taşıyıcı bırakmak. Modelle doğrudan ilgili basit kurallar (örneğin bir hesaplama veya durum geçişi) modelde kalabilir.
- Service içinde HTTP kavramları:
Request,response(),abort()gibi çağrılar Service'e sızarsa, Service'i konsoldan veya kuyruktan çağırmak imkânsız hâle gelir. - Aşırı soyutlama: Her model için interface + repository + service üçlüsü yazmak, küçük projelerde hızı düşürür. İhtiyaç doğdukça katman ekleyin.
- Transaction'ı unutmak: Çok adımlı yazma işlemlerinde transaction olmadan yarım kalan veri oluşur.
- Service'i şişirmek: Tek bir Service sınıfı onlarca metoda ulaşırsa, sorumluluğa göre bölün (örneğin
OrderServiceveOrderRefundService).
Güvenlik Etkileri
- Mass assignment:
Model::create($request->all())yerinevalidated()veya açık alan listesi kullanın.$fillabletanımlı olsa bile, doğrulanmamış alanların modele sızmasını engellemek en güvenli yoldur. - Yetkilendirme: Form Request
authorize()içinde veya policy ile yapılmalıdır. Service içindeauth()->user()çağırmak, Service'i HTTP'ye bağımlı hâle getirir ve test etmeyi zorlaştırır. Yetkiyi controller/policy katmanında uygulayın. - Yarış koşulları: Stok, bakiye gibi paylaşılan kaynaklarda
lockForUpdateveya atomikdecrement/incrementkullanın. Yalnızca PHP tarafında kontrol edip sonra yazmak, eşzamanlı isteklerde veri tutarsızlığına yol açar. - Hata mesajları: Kullanıcıya dönen mesajlarda iç veritabanı ayrıntılarını sızdırmayın.
ValidationExceptionmesajları kullanıcıya uygun olmalı; beklenmeyen istisnaları loglayıp genel bir 500 yanıtı döndürün.
Test Edilebilirlik
Katmanlı yapının en büyük kazancı test kolaylığıdır. Service'i test ederken HTTP katmanına ihtiyaç duymazsınız:
public function test_stok_yetersizse_siparis_olusturulmaz(): void
{
$product = Product::factory()->create(['stock' => 1]);
$this->expectException(\Illuminate\Validation\ValidationException::class);
app(OrderService::class)->createOrder(
customerId: \App\Models\Customer::factory()->create()->id,
items: [['product_id' => $product->id, 'quantity' => 5]],
);
$this->assertDatabaseCount('orders', 0);
}
Bu test, transaction'ın geri alındığını ve sipariş kaydının oluşmadığını doğrular. Yukarıdaki örnek, mantık olarak bu rehberdeki Service koduna dayanır; kendi projenizde çalıştırmadan önce test altyapınızı ve factory tanımlarınızı doğrulayın.
Sonuç
Service Layer, Laravel projelerinde iş kurallarını HTTP'den ayırmanın en pratik yoludur. Clean Architecture'ın tamamını birebir uygulamak yerine, controller'ı ince tutmak, iş akışını Service'e taşımak, transaction sınırını doğru yere koymak ve doğrulama/yetkilendirmeyi HTTP katmanında bırakmak, çoğu projede yeterli ve sürdürülebilir bir denge sağlar. Katman eklerken her zaman şu soruyu sorun: bu soyutlama, somut bir sorunu çözüyor mu? Cevap net değilse, eklemeyin.