22.12.2025

Laravel’de Domain Katmanı: Action + DTO ile Controller’ları İncelterek Sürdürülebilir Kod

Controller’ları incelten Action ve DTO yaklaşımıyla Laravel’de domain odaklı, okunabilir ve test edilebilir yapı kurun.

Laravel projeleri büyüdükçe controller’lar hızla “her şeyi yapan” dosyalara dönüşebilir. Bu yazıda, mevcut alışıldık konuların (API, queue, log, test vs.) dışına çıkıp domain katmanını netleştiren bir pratik ele alacağız: Action (Use Case) + DTO (Data Transfer Object) yaklaşımı.

Neden Action + DTO?

  • Tek sorumluluk: Controller sadece HTTP ile ilgilenir.
  • Yeniden kullanım: Aynı iş akışı (use case) CLI/Job/Controller’dan çağrılabilir.
  • Daha okunabilir validasyon: “Request validated array” yerine anlamlı bir DTO.
  • Test kolaylığı: HTTP’siz, saf PHP ile use case testi.

Örnek Senaryo: Ürün İndirimi Uygulama

Bir e-ticarette “ürüne yüzde indirim uygula” işini ele alalım.

1) DTO: İşi temsil eden veri

app/Domain/Pricing/ApplyDiscountData.php

<?php

namespace App\Domain\Pricing;

final class ApplyDiscountData
{
    public function __construct(
        public readonly int $productId,
        public readonly int $percent,
        public readonly int $actorUserId,
    ) {}

    public static function from(array $validated, int $actorUserId): self
    {
        return new self(
            productId: (int) $validated['product_id'],
            percent: (int) $validated['percent'],
            actorUserId: $actorUserId,
        );
    }
}

2) Action: Use case’i yapan sınıf

app/Domain/Pricing/ApplyDiscountAction.php

<?php

namespace App\Domain\Pricing;

use App\Models\Product;
use Illuminate\Support\Facades\DB;
use DomainException;

final class ApplyDiscountAction
{
    public function execute(ApplyDiscountData $data): Product
    {
        if ($data->percent < 1 || $data->percent > 90) {
            throw new DomainException('İndirim yüzde 1-90 arası olmalıdır.');
        }

        return DB::transaction(function () use ($data) {
            $product = Product::lockForUpdate()->findOrFail($data->productId);

            $newPrice = (int) round($product->price * (100 - $data->percent) / 100);

            if ($newPrice >= $product->price) {
                throw new DomainException('Yeni fiyat eski fiyattan düşük olmalı.');
            }

            $product->update([
                'price' => $newPrice,
                'discount_percent' => $data->percent,
                'discounted_by' => $data->actorUserId,
            ]);

            return $product->refresh();
        });
    }
}

3) Request: Validasyonu HTTP katmanında tut

app/Http/Requests/ApplyDiscountRequest.php

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ApplyDiscountRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'product_id' => ['required', 'integer', 'exists:products,id'],
            'percent' => ['required', 'integer', 'min:1', 'max:90'],
        ];
    }
}

4) Controller: İnce ve net

app/Http/Controllers/ProductDiscountController.php

<?php

namespace App\Http\Controllers;

use App\Domain\Pricing\ApplyDiscountAction;
use App\Domain\Pricing\ApplyDiscountData;
use App\Http\Requests\ApplyDiscountRequest;
use DomainException;

class ProductDiscountController
{
    public function __invoke(ApplyDiscountRequest $request, ApplyDiscountAction $action)
    {
        try {
            $dto = ApplyDiscountData::from($request->validated(), $request->user()->id);
            $product = $action->execute($dto);

            return response()->json([
                'ok' => true,
                'product' => $product,
            ]);
        } catch (DomainException $e) {
            return response()->json([
                'ok' => false,
                'message' => $e->getMessage(),
            ], 422);
        }
    }
}

Pratik İpuçları

  • Klasörleme: app/Domain/Pricing gibi domain bazlı bir yapı, “Services” klasöründen daha anlamlıdır.
  • İsimlendirme: Action yerine UseCase veya Service de kullanılabilir; önemli olan niyetin açık olması.
  • Domain hataları: DomainException (veya özel exception sınıfları) ile “iş kuralı” ihlallerini ayırın.
  • Yeniden kullanım: Aynı ApplyDiscountAction bir artisan command veya admin panelinden de çağrılabilir.

Sonuç

Action + DTO yaklaşımı, Laravel’in esnek yapısını bozmadan domain odaklı, okunabilir ve bakımı kolay bir mimari kurmanıza yardım eder. Controller’lar incelir, iş kuralları tek yerde toplanır ve proje büyüdükçe “spagetti”leşme riski azalır.