· Admin

Laravel'de Katmanlı Mimari: Controller → Service → Repository

Laravel projelerinde en sık gördüğüm sorun şu: controller'lar 500 satır, model'ler 300 satır, business logic her yere dağılmış. Proje büyüdükçe nereye ne yazacağınızı bilemez hale geliyorsunuz. Çözüm basit aslında — katmanları ayırın.

Bu yazıda Controller, Service, Repository ve Model katmanlarını neden ve nasıl ayırdığımı anlatacağım. Kod örnekleri gerçek projelerden sadeleştirilmiş hali.

Neden Katmana İhtiyacımız Var?

Tipik bir Laravel projesi şöyle başlar:

// Herkes böyle başlıyor, sorun yok
public function index()
{
    $users = User::all();
    return view('users.index', compact('users'));
}

Sonra ihtiyaçlar artar, controller şuna dönüşür:

public function store(Request $request)
{
    $request->validate([...]);

    $user = User::create($request->all());
    $user->roles()->attach($request->roles);

    // Mail gönder
    Mail::to($user)->send(new WelcomeMail($user));

    // Loglama
    activity()->log('Yeni kullanıcı oluşturuldu');

    // Cache temizle
    Cache::forget('users_list');

    // Bildirim at
    Notification::send($admins, new NewUserNotification($user));

    return redirect()->route('users.index')->with('success', '...');
}

Controller'ın işi request almak ve response döndürmek. Geri kalan her şey — validation hariç — yanlış yerde duruyor.

Katmanlar ve Sorumlulukları

Kural basit: her katman sadece kendisinin altındaki katmanla konuşur.

Request → Controller → Service → Repository → Model → Database

Controller: Trafik Polisi

Controller sadece iki şey yapar: gelen request'i al, ilgili service'e ilet. Gelen response'u formatla, geri dön.

class UserController extends Controller
{
    public function __construct(
        private UserService $userService
    ) {}

    public function index()
    {
        $users = $this->userService->getAll();
        return view('users.index', compact('users'));
    }

    public function store(StoreUserRequest $request)
    {
        $this->userService->create($request->validated());
        return redirect()->route('users.index')
            ->with('success', 'Kullanıcı oluşturuldu.');
    }
}

Dikkat edin: iş mantığı yok burada. StoreUserRequest ile validation da controller'dan ayrıldı.

Service: Beyin

Tüm iş mantığı burada yaşar. "Kullanıcı oluştur" dediğimizde yapılması gereken adımları service yönetir.

Önce interface tanımlayalım:

namespace App\Services;

interface UserServiceInterface
{
    public function getAll();
    public function create(array $data);
    public function findById(int $id);
}

Sonra implementasyon:

namespace App\Services;

use App\Repositories\UserRepositoryInterface;
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeMail;

class UserService implements UserServiceInterface
{
    public function __construct(
        private UserRepositoryInterface $userRepo
    ) {}

    public function getAll()
    {
        return $this->userRepo->all();
    }

    public function create(array $data)
    {
        $user = $this->userRepo->create($data);

        if (!empty($data['roles'])) {
            $user->roles()->attach($data['roles']);
        }

        Mail::to($user)->queue(new WelcomeMail($user));

        return $user;
    }

    public function findById(int $id)
    {
        return $this->userRepo->find($id);
    }
}

Service katmanının repository'den haberi var ama controller'dan haberi yok. Request nesnesi buraya gelmiyor — sadece sade array veya DTO geliyor. Bu sayede service'i hem web controller'dan hem API controller'dan hem artisan command'dan çağırabilirsiniz.

Repository: Veri Kapısı

Repository katmanı veritabanıyla konuşur. Yarın MySQL'den PostgreSQL'e geçseniz, sadece repository'i değiştirirsiniz. Service katmanı bundan habersiz kalır.

Interface:

namespace App\Repositories;

interface UserRepositoryInterface
{
    public function all();
    public function find(int $id);
    public function create(array $data);
    public function update(int $id, array $data);
    public function delete(int $id);
}

Implementasyon:

namespace App\Repositories;

use App\Models\User;

class UserRepository implements UserRepositoryInterface
{
    public function __construct(
        private User $model
    ) {}

    public function all()
    {
        return $this->model->latest()->get();
    }

    public function find(int $id)
    {
        return $this->model->findOrFail($id);
    }

    public function create(array $data)
    {
        return $this->model->create($data);
    }

    public function update(int $id, array $data)
    {
        $record = $this->find($id);
        $record->update($data);
        return $record;
    }

    public function delete(int $id)
    {
        return $this->find($id)->delete();
    }
}

Burada Eloquent kullanıyoruz ama yarın Redis cache eklemek istesek CachedUserRepository yazıp all() metodunda önce cache'e bakabiliriz. Service katmanı hiçbir şeyden habersiz.

Model: Veritabanı Aynası

Model sadece tablo yapısını, ilişkileri ve basit scope'ları barındırır. İş mantığı model'de olmamalı.

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    protected $fillable = ['name', 'email', 'password'];

    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }

    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }

    public function scopeActive($query)
    {
        return $query->where('is_active', true);
    }
}

Interface Binding

Interface'leri concrete class'lara bağlamak için AppServiceProvider kullanıyorum:

public function register(): void
{
    $this->app->bind(
        \App\Services\UserServiceInterface::class,
        \App\Services\UserService::class
    );

    $this->app->bind(
        \App\Repositories\UserRepositoryInterface::class,
        \App\Repositories\UserRepository::class
    );
}

Test Yazabilirlik

Bu yapının en büyük avantajı test yazılabilirlik. Service'i test ederken repository'i mock'layabilirsiniz:

class UserServiceTest extends TestCase
{
    public function test_create_user_sends_welcome_mail()
    {
        Mail::fake();

        $mockRepo = Mockery::mock(UserRepositoryInterface::class);
        $mockRepo->shouldReceive('create')
            ->once()
            ->andReturn(new User(['name' => 'Test', 'email' => '[email protected]']));

        $service = new UserService($mockRepo);
        $service->create(['name' => 'Test', 'email' => '[email protected]']);

        Mail::assertQueued(WelcomeMail::class);
    }
}

Veritabanına dokunmadan service'in davranışını test ettik. Controller testi de basitleşir çünkü zaten controller'da test edilecek iş mantığı yok.

Bu Yapıyı Her Projede Kullanmalı mıyım?

Hayır. Küçük projelerde — 10-15 model, 2-3 geliştirici — bu yapı overengineering olur. Controller'da basit CRUD yapın, geçin. Laravel zaten Eloquent'i bu kadar güçlü yaparak "hızlıca bir şey çıkar" demeye çalışıyor.

Ama şu durumlarda bu yapıya geçin:

  • Aynı iş mantığını birden fazla yerden çağırıyorsanız (web + API + command)
  • Controller'larınız 200 satırı geçtiyse
  • Ekip büyüyorsa ve herkes birbirinin koduna basıyorsa
  • Test yazmak istiyorsanız ama her test veritabanına bağımlıysa

Kademeli geçiş de mümkün. Önce en karmaşık controller'ı refactor edin, çalıştığını görün, sonra diğerlerine geçin. Tüm projeyi bir günde değiştirmeye çalışmayın.

Dizin Yapısı

Sonuç olarak proje yapınız şöyle görünecek:

app/
├── Http/
│   └── Controllers/
│       └── UserController.php
├── Models/
│   └── User.php
├── Repositories/
│   ├── UserRepositoryInterface.php
│   └── UserRepository.php
└── Services/
    ├── UserServiceInterface.php
    └── UserService.php

Temiz, öngörülebilir, test edilebilir. Yeni gelen bir geliştirici "ödeme mantığı nerede?" diye sorduğunda cevap her zaman aynı: App\Services\PaymentService.