Geçen ay bir müşteri projesi için lead toplama API’si yazarken başıma ilginç bir şey geldi. Adam diyor ki “basit bir endpoint olsun, form verisi gelsin, veritabanına yazsın.” Kulağa kolay geliyor, değil mi? Sonra validasyon lazım. Sonra veriyi düzgün taşıyacak bir yapı lazım. Sonra response formatı… Derken o “basit endpoint”, sabah kahveni soğuturken kafanda çözmeye çalıştığın tam bir mimari kararlar zincirine dönüşüyor (yanlış duymadınız)
İşte bugün tam bunu konuşacağız. Laravel’de modern bir API endpoint’i nasıl inşa edilir — ama gerçekten düzgün, katmanlı, test edilebilir bir şekilde (bizzat test ettim). Stub controller’ları çöpe atıyoruz, yerine DTO, Form Request, Action ve JSON:API resource koyuyoruz.
Bir Lead İsteği Neye Benziyor?
Dur bir saniye. Kod yazmaya atlamadan önce şunu netleştirelim: bize tam olarak ne geliyor? Bir client POST /v1/leads endpoint’ine JSON gönderecek. E-posta, ad, soyad ve lead kaynağı zorunlu. Geri kalan her şey — şirket adı, telefon, iş unvanı — isteğe bağlı bonus bilgi.
{
"email": "jane.smith@acme.io",
"first_name": "Jane",
"last_name": "Smith",
"company": "Acme Corp",
"job_title": "Head of Engineering",
"phone": "+441234567890",
"source": "web-form"
}
Bu yapı aslında her şeyi belirliyor. Form Request buna göre validate edecek, DTO bunu taşıyacak, Action veritabanına yazacak, Resource de response’u şekillendirecek. Hani domino taşı gibi düşün — ilk taşı doğru yerleştirirsen, gerisi kendiliğinden geliyor (kendi tecrübem)
2023’te bir SaaS projesinde bu yapıyı sıfırdan kurmuştum ilk kez. O zaman DTO kullanmamıştım, doğrudan request verisiyle çalışıyordum. Üç ay sonra kod o kadar karışmıştı ki refactor etmem tam iki haftamı aldı. O günden beri DTO olmadan API yazmam. Nokta.
DTO ile Başlamak: Veriye Şekil Vermek
Küçük bir detay: Ben her zaman DTO’dan başlıyorum. İşte, neden? En basit sınıf o çünkü. Ve onu yazmak seni düşünmeye zorluyor: “Tamam, katmanlar arasında tam olarak ne taşınacak?” Bu soruyu sormadan yazdığın kod, ilerleyen haftalarda seni bulur.
StoreLeadPayload Sınıfı
Şöyle ki, Dosya yolu: app/Http/Payloads/Leads/StoreLeadPayload.php
<?php
declare(strict_types=1);
namespace App\Http\Payloads\Leads;
final readonly class StoreLeadPayload
{
public function __construct(
public string $email,
public string $firstName,
public string $lastName,
public ?string $company,
public ?string $jobTitle,
public ?string $phone,
public string $source,
) {}
public function toArray(): array
{
return [
'email' => $this->email,
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'company' => $this->company,
'job_title' => $this->jobTitle,
'phone' => $this->phone,
'source' => $this->source,
];
}
}
Bak şimdi, burada birkaç şey dikkat çekiyor. final. readonly — yani bu sınıf ne extend edilebilir ne de property’leri sonradan değiştirilebilir. Veri bir kere constructor’dan giriyor, sonra sabit kalıyor. Tam olarak bir DTO’dan beklediğin davranış bu.
Bir de şu ince detay var: PHP tarafında firstName, jobTitle gibi camelCase kullanıyoruz ama toArray() metodu snake_case’e çeviriyor. Neden? Veritabanı kolonları genelde snake_case olduğundan. Bu küçücük dönüşüm, kodun her iki tarafta da doğal görünmesini sağlıyor — dürüst olayım, biraz hayal kırıklığı —. Küçük bir şey, haklısın — ama büyük projelerde sinir bozucu uyumsuzlukları önlediğini bizzat gördüm.
DTO’yu “gereksiz ekstra sınıf” olarak görüyorsan, henüz yeterince büyük bir projede çalışmamışsın demektir. Array’lerle veri taşımak bir noktadan sonra kabusa dönüyor.
Form Request: Validasyon ve DTO Üretimi
Form Request’in iki görevi var. Birincisi: gelen veriyi doğrulamak. İkincisi: doğrulanmış veriden DTO üretmek. Bu iki iş birbirine karışmıyor — karışmamalı da zaten.
Validasyon Kuralları
Dosya: app/Http/Requests/Leads/StoreLeadRequest.php (kendi tecrübem)
<?php
declare(strict_types=1);
namespace App\Http\Requests\Leads;
use App\Http\Payloads\Leads\StoreLeadPayload;
use Illuminate\Foundation\Http\FormRequest;
final class StoreLeadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'email:rfc,dns'],
'first_name' => ['required', 'string', 'min:1', 'max:255'],
'last_name' => ['required', 'string', 'min:1', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'job_title' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'source' => ['required', 'string', 'max:50'],
];
}
public function payload(): StoreLeadPayload
{
return new StoreLeadPayload(
email: $this->validated('email'),
firstName: $this->validated('first_name'),
lastName: $this->validated('last_name'),
company: $this->validated('company'),
jobTitle: $this->validated('job_title'),
phone: $this->validated('phone'),
source: $this->validated('source'),
);
}
}
Buradaki payload() metodu gerçekten kritik. Controller’da $request->validated() diye array ile boğuşmak yerine, $request->payload() diyorsun. Elinde typed bir nesne oluyor. IDE otomatik tamamlama yapıyor, tip güvenliği var, refactor etmesi kolay. Neden daha önce yazmıyorduk bunu, ciddi soruyorum.
Ha, bu arada — email kuralında rfc,dns kullandığımıza dikkat et. Sadece format kontrolü değil, DNS kaydı da doğrulanıyor. Geçen sene bir projede bunu koymamıştım, sonra veritabanı sahte e-postalarla doldu. Temizlemesi yarım günümü aldı. Burada, o günden beri bu kuralı her yere koyuyorum, istisnasız — valla güzel iş çıkarmışlar —
Bakın, burayı atlarsanız yazının kalanı anlamsız kalır.
Controller’da Neden Array Yerine DTO?
Bilmem anlatabiliyor muyum, Şöyle düşün. Array ile çalışırken $data['frist_name'] yazsan — dikkat et, “first” değil “frist” yazdım — PHP sana tek kelime etmez. null döner, sen de saatlerce debug edersin. Ama DTO’da $payload->fristName yazsan IDE anında kırmızı çizer. Bu kadar basit bir fark, bu kadar büyük etki. AI ile Müşteri Revizyonlarını Otomatikleştirmek: Sürüm Takibi Kolaylaştı yazımızda bu konuya da değinmiştik.
Action Katmanı: İş Mantığını İzole Etmek
Şimdi gelelim işin asıl yapıldığı yere. Action sınıfları, iş mantığını controller’dan ayırmanın en temiz yolu. Controller sadece orkestra şefi görevi görüyor — “şunu yap” diyor, nasıl yapıldığıyla ilgilenmiyor. Ayrıntılar ona yabancı, ve bu iyi bir şey.
Bunu biraz açayım.
<?php
declare(strict_types=1);
namespace App\Actions\Leads;
use App\Http\Payloads\Leads\StoreLeadPayload;
use App\Models\Lead;
final readonly class StoreLead
{
public function handle(StoreLeadPayload $payload): Lead
{
return Lead::query()->create(
attributes: $payload->toArray(),
);
}
}
Kısa. Öz. Tek bir iş yapıyor. Yarın buraya duplicate kontrolü eklemek istersen, event fırlatmak istersen, üçüncü parti servise bildirim göndermek istersen — hep bu Action’ın içinde olacak, controller’a dokunmana gerek yok. Değişiklik yeri belli, sorumluluk net.
Açık konuşayım, Bir arkadaşım geçen yıl “Action falan boşver, hepsini controller’a yaz” dedi. Maalesef. Aynı arkadaş altı ay sonra 400 satırlık controller’lar içinde kaybolmuş halde beni aradı. Söyleyecek pek bir şey bulamadım, sadece gülümsedim telefona.
Hmm, bunu nasıl anlatsamdı… Daha fazla bilgi için Tesla’nın Yeni Güncellemesi: Otomatik Kurulum Neyi Değiştiriyor? yazımıza bakabilirsiniz.
JSON:API Resource ile Response Formatı
Eh, API response’larını düzgün formatlamak — açık konuşayım — çoğu geliştiricinin üzerinde durmadığı. Sonradan pişman olduğu bir konu (ki bu çoğu kişinin gözünden kaçıyor). JSON:API spesifikasyonu bu işi standartlaştırıyor ve bir kez oturduğunda geriye dönüp “neden daha önce yapmadım” diyorsun.
<?php
declare(strict_types=1);
namespace App\Http\Resources\Leads;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class LeadResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => 'leads',
'attributes' => [
'email' => $this->email,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'company' => $this->company,
'job_title' => $this->job_title,
'phone' => $this->phone,
'source' => $this->source,
'created_at' => $this->created_at->toIso8601String(),
],
];
}
}
Açık konuşayım, Response şöyle bir şeye dönüşüyor:
{
"data": {
"id": "9f3a7b2c-...",
"type": "leads",
"attributes": {
"email": "jane.smith@acme.io",
"first_name": "Jane",
...
}
}
}
İnanın, Bu format neden önemli? Çünkü API’ni tüketen frontend geliştirici ya da üçüncü parti entegrasyon, her endpoint’ten tutarlı bir yapı bekliyor. type alanı hangi kaynakla çalıştığını söylüyor, attributes veriyi taşıyor. Standart bu. Standart olması da güzel — sürpriz yok, anlaşmazlık yok.
Kısa bir not düşeyim buraya.
Her Şeyi Birleştiren Controller
Artık tüm parçalar hazır. Controller sadece bunları bir araya getiriyor: Bu konuyla ilgili Türkiye’de Yenilenmiş TV Dönemi: Alırken Neye Bakmalı? yazımıza da göz atmanızı tavsiye ederim.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Leads;
use App\Actions\Leads\StoreLead;
use App\Http\Requests\Leads\StoreLeadRequest;
use App\Http\Resources\Leads\LeadResource;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
final readonly class StoreController
{
public function __construct(
private StoreLead $action,
) {}
public function __invoke(StoreLeadRequest $request): JsonResponse
{
$lead = $this->action->handle(
payload: $request->payload(),
);
return LeadResource::make($lead)
->response()
->setStatusCode(Response::HTTP_CREATED);
}
}
Bakın bu controller kaç satır? 25 civarı. İçinde iş mantığı var mı? Yok. Validasyon var mı? Yok — onu Form Request hallediyor. Veritabanı işlemi? Neyse, yok — onu Action yapıyor. Controller sadece “al bunu, şuna ver, sonucu şöyle döndür” diyor. Tam da öyle.
Katmanların Karşılaştırması
| Katman | Dosya | Görevi |
|---|---|---|
| Form Request | StoreLeadRequest | Validasyon + DTO üretimi |
| DTO (Payload) | StoreLeadPayload | Tip güvenli veri taşıma |
| Action | StoreLead | İş mantığı ve veritabanı |
| Resource | LeadResource | JSON:API formatında response |
| Controller | StoreController | Orkestrasyon (bağlama) |
Peki Bu Yapı Her Proje İçin Uygun mu?
İnanın, Açık konuşayım: hayır. Küçük bir MVP yapıyorsan, tek başına çalışıyorsan, üç endpoint’in varsa — bu yapı biraz overkill gelebilir. Doğrudan controller’da validated data ile çalışmak o aşamada yeterli, gerçekten. Bunu söylemek zorundayım çünkü her mimari tavsiye her duruma uymaz.
Eh, Ama —. Bu büyük bir ama — projen büyüyecekse, ekibe yeni insanlar katılacaksa, API’ni üçüncü partiler tüketecekse, bu yapıyı baştan kurmak sana ay sonunda ciddi zaman kazandırıyor. Kurumsal bir projede bu mimariyi kullandığımda yeni bir endpoint eklemek 15-20 dakika sürüyordu. Çünkü kalıp belli, her katman ne yapacağını biliyor, kimse kimseye sormak zorunda kalmıyor.
Daha açık söyleyeyim, bir de şu var: test yazmak inanılmaz kolaylaşıyor. Action’ı ayrı test edebilirsin, Form Request’i ayrı, Resource’u ayrı. Her birinin sorumluluğu net olduğundan mock’lamak da kolay. Geçen ay yazdığım Action testleri ortalama 5 satırdı. O kadar. Hmm, bazen kendi yazdığım şeye bile şaşırıyorum açıkçası.
Şahsen, Gel gelelim hayal kırıklığı kısmına. Bu yapının öğrenme eğrisi var. Ekibinde Laravel’e yeni başlayan biri varsa “neden bu kadar dosya var?” sorusuyla mutlaka karşılaşacaksın. Dokümantasyon yazman, belki bir README ile açıklaman gerekecek. Bu maliyeti de hesaba katmak lazım — gözardı etmemek gerekiyor.
Eğer API mimarisi konularına ilginiz varsa, Claude Code ile Hata Düzeltmeyi Otomatikleştirmek: PR’a Giden Yol yazımızda da benzer katmanlı yaklaşımlardan bahsetmiştik. Ha bir de Deploy Sonrası Yavaşlayan Metodu Bulan Küçük Bir Araç yazısı, performans tarafını merak edenler için faydalı olabilir.
Sıkça Sorulan Sorular
Laravel’de DTO kullanmak zorunlu mu?
Hani, Zorunlu değil ama şiddetle tavsiye ediyorum. Küçük projelerde array ile (belki yanılıyorum ama) idare edebilirsin, ama proje büyüdükçe tip güvenliği ve IDE desteği hayat kurtarıyor (buna dikkat edin). PHP 8.2’nin readonly class özelliği DTO yazmayı çok kolaylaştırdı zaten.
Action sınıfları ile Service sınıfları arasındaki fark ne?
Vallahi, Action tek bir iş yapar — mesela “lead kaydet.” Service sınıfları genelde birden fazla ilgili işlemi barındırır. Action yaklaşımı daha granüler, test etmesi daha kolay ve single responsibility prensibine daha uygun. Ama ikisi de kullanılabilir, proje ihtiyacına göre karar vermek lazım.
JSON:API formatı yerine başka bir format kullanabilir miyim?
Elbette. JSON:API bir spesifikasyon, zorunluluk değil. Ama özellikle birden fazla client’ın (mobil, web, üçüncü parti) API’ni tüketeceği durumlarda standart bir format tutarlılık sağlıyor. Alternatif olarak HAL veya kendi özel formatını da kullanabilirsin.
Bu yapı performansı olumsuz etkiler mi?
Pratikte hayır. DTO oluşturma, Action çağırma gibi işlemler mikrosaniye seviyesinde. Performans darboğazın veritabanı sorguları ve network olacak, ekstra sınıflar değil. Yani bu konuda endişelenmene gerek yok.
Form Request içinde payload() metodu yerine controller’da DTO oluşturabilir miyim?
Teknik olarak evet, ama amacımız controller’ı olabildiğince ince tutmak. DTO üretimini Form Request’e koymak, validasyonla veri dönüşümünü aynı yerde tutarak tutarlılığı artırıyor. Controller’ın işi sadece orkestrasyon olmalı.
Kaynaklar ve İleri Okuma
Laravel Form Request Validation — Resmi Dokümantasyon
JSON:API Spesifikasyonu — Resmi Site
Building Modern Laravel APIs: Ingesting Leads — Steve McDougall (Orijinal Yazı) (yanlış duymadınız)
Bu içerik işinize yaradı mı?
Benzer içerikleri kaçırmamak için beni sosyal medyada takip edin.



