· Admin

Laravel ile SaaS Uygulama Gelistirme Rehberi

SaaS (Software as a Service) uygulamasi gelistirmek, standart bir web uygulamasindan farkli zorluklar iceriyor. Multi-tenancy, abonelik yonetimi, izolasyon, olcekleme... Bu yazida Laravel ile production-ready bir SaaS uygulamasi kurarken uyguladigim pratikleri paylasiyorum.

Multi-Tenancy Yaklasimi

SaaS'ta en kritik karar: verileri nasil izole edeceksin?

Uc Yaklasim

Yaklasim Aciklama Artisi Eksisi
Tek DB, tenant_id Tum tenantlar ayni tabloda Basit, ucuz Izolasyon zayif
Ayri schema Her tenant icin ayri schema Iyi izolasyon Orta karmasiklik
Ayri DB Her tenant icin ayri veritabani Tam izolasyon Pahali, karmasik

Cogu SaaS icin tek DB + tenant_id yeterli ve tavsiye ettigim yaklasim bu. Ihtiyac buyudukce ayri schema'ya gecebilirsin.

Tek DB ile Multi-Tenancy

1. Tenant Modeli

// app/Models/Tenant.php
class Tenant extends Model
{
    protected $fillable = [
        'name',
        'slug',
        'domain',
        'settings',
        'plan',
        'is_active',
    ];

    protected $casts = [
        'settings' => 'array',
        'is_active' => 'boolean',
    ];

    public function users()
    {
        return $this->hasMany(User::class);
    }

    public function owner()
    {
        return $this->belongsTo(User::class, 'owner_id');
    }
}

2. Migration

Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('domain')->nullable()->unique();
    $table->foreignId('owner_id')->constrained('users');
    $table->json('settings')->nullable();
    $table->string('plan')->default('free');
    $table->boolean('is_active')->default(true);
    $table->timestamps();
});

// Users tablosuna tenant_id ekle
Schema::table('users', function (Blueprint $table) {
    $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete();
});

3. BelongsToTenant Trait

Bu trait, tenant'a ait tum modellere eklenir. Otomatik olarak tenant_id filtresi uygular.

// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;

use App\Models\Tenant;
use App\Scopes\TenantScope;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        // Otomatik tenant_id ata
        static::creating(function ($model) {
            if (! $model->tenant_id && auth()->check()) {
                $model->tenant_id = auth()->user()->tenant_id;
            }
        });

        // Global scope ekle
        static::addGlobalScope(new TenantScope());
    }

    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

4. TenantScope

// app/Scopes/TenantScope.php
namespace App\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (auth()->check() && auth()->user()->tenant_id) {
            $builder->where($model->getTable() . '.tenant_id', auth()->user()->tenant_id);
        }
    }
}

5. Modellerde Kullanim

class Project extends Model
{
    use BelongsToTenant;

    protected $fillable = ['name', 'description', 'status', 'tenant_id'];
}

class Invoice extends Model
{
    use BelongsToTenant;

    protected $fillable = ['number', 'amount', 'status', 'tenant_id'];
}

Artik Project::all() dediginde sadece mevcut tenant'in projeleri gelir. Ekstra where yazmana gerek yok.

6. Admin Icin Scope Atlama

// Admin panelinde tum verileri gormek istiyorsan
$allProjects = Project::withoutGlobalScope(TenantScope::class)->get();

// veya belirli bir tenant'in verileri
$projects = Project::withoutGlobalScope(TenantScope::class)
    ->where('tenant_id', $tenantId)
    ->get();

Abonelik Yonetimi (Laravel Cashier)

Stripe Entegrasyonu

composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate

Tenant modeline Billable trait'i ekle:

use Laravel\Cashier\Billable;

class Tenant extends Model
{
    use Billable;
}

Plan Tanimlari

// config/plans.php
return [
    'free' => [
        'name' => 'Ucretsiz',
        'price_monthly' => 0,
        'stripe_price_id' => null,
        'features' => [
            'max_users' => 2,
            'max_projects' => 5,
            'storage_mb' => 100,
            'api_access' => false,
        ],
    ],
    'starter' => [
        'name' => 'Baslangic',
        'price_monthly' => 29,
        'stripe_price_id' => env('STRIPE_STARTER_PRICE_ID'),
        'features' => [
            'max_users' => 10,
            'max_projects' => 50,
            'storage_mb' => 5000,
            'api_access' => true,
        ],
    ],
    'pro' => [
        'name' => 'Profesyonel',
        'price_monthly' => 79,
        'stripe_price_id' => env('STRIPE_PRO_PRICE_ID'),
        'features' => [
            'max_users' => -1, // Sinirsiz
            'max_projects' => -1,
            'storage_mb' => 50000,
            'api_access' => true,
        ],
    ],
];

Abonelik Islemleri

// app/Http/Controllers/SubscriptionController.php
class SubscriptionController extends Controller
{
    public function subscribe(Request $request)
    {
        $request->validate([
            'plan' => ['required', 'in:starter,pro'],
            'payment_method' => ['required', 'string'],
        ]);

        $tenant = auth()->user()->tenant;
        $plan = config("plans.{$request->plan}");

        $tenant->newSubscription('default', $plan['stripe_price_id'])
            ->create($request->payment_method);

        $tenant->update(['plan' => $request->plan]);

        return redirect()->route('settings.billing')
            ->with('success', "{$plan['name']} planina gecis yapildi.");
    }

    public function changePlan(Request $request)
    {
        $request->validate([
            'plan' => ['required', 'in:starter,pro'],
        ]);

        $tenant = auth()->user()->tenant;
        $plan = config("plans.{$request->plan}");

        $tenant->subscription('default')->swap($plan['stripe_price_id']);
        $tenant->update(['plan' => $request->plan]);

        return back()->with('success', 'Plan degistirildi.');
    }

    public function cancel()
    {
        $tenant = auth()->user()->tenant;
        $tenant->subscription('default')->cancel();

        return back()->with('success', 'Abonelik donem sonunda iptal edilecek.');
    }

    public function resume()
    {
        $tenant = auth()->user()->tenant;
        $tenant->subscription('default')->resume();

        return back()->with('success', 'Abonelik devam ettirildi.');
    }

    public function billingPortal()
    {
        return auth()->user()->tenant->redirectToBillingPortal(
            route('settings.billing')
        );
    }
}

Plan Limitleri Kontrolu

// app/Services/PlanLimitService.php
class PlanLimitService
{
    public function canAddUser(Tenant $tenant): bool
    {
        $limit = $this->getFeature($tenant, 'max_users');
        if ($limit === -1) return true;

        return $tenant->users()->count() < $limit;
    }

    public function canCreateProject(Tenant $tenant): bool
    {
        $limit = $this->getFeature($tenant, 'max_projects');
        if ($limit === -1) return true;

        return $tenant->projects()->count() < $limit;
    }

    public function getFeature(Tenant $tenant, string $feature): mixed
    {
        return config("plans.{$tenant->plan}.features.{$feature}");
    }

    public function getRemainingProjects(Tenant $tenant): int|string
    {
        $limit = $this->getFeature($tenant, 'max_projects');
        if ($limit === -1) return 'Sinirsiz';

        return max(0, $limit - $tenant->projects()->count());
    }
}

Middleware ile kontrol:

// app/Http/Middleware/CheckPlanLimit.php
class CheckPlanLimit
{
    public function handle(Request $request, Closure $next, string $feature)
    {
        $tenant = $request->user()->tenant;
        $service = app(PlanLimitService::class);

        $check = match ($feature) {
            'users' => $service->canAddUser($tenant),
            'projects' => $service->canCreateProject($tenant),
            default => true,
        };

        if (! $check) {
            if ($request->expectsJson()) {
                return response()->json([
                    'message' => 'Plan limitinize ulastiniz. Lutfen planinizi yukseltin.',
                ], 403);
            }

            return redirect()->route('settings.billing')
                ->with('error', 'Plan limitinize ulastiniz.');
        }

        return $next($request);
    }
}
// Route'larda
Route::post('/projects', [ProjectController::class, 'store'])
    ->middleware('plan.limit:projects');

Route::post('/team/invite', [TeamController::class, 'invite'])
    ->middleware('plan.limit:users');

Takim Yonetimi

// app/Http/Controllers/TeamController.php
class TeamController extends Controller
{
    public function invite(Request $request)
    {
        $request->validate([
            'email' => ['required', 'email'],
            'role' => ['required', 'in:admin,member,viewer'],
        ]);

        $tenant = auth()->user()->tenant;

        // Kullanici zaten uyemi?
        $existing = User::where('email', $request->email)
            ->where('tenant_id', $tenant->id)
            ->exists();

        if ($existing) {
            return back()->with('error', 'Bu kullanici zaten takim uyesi.');
        }

        // Davetiye olustur
        $invitation = TeamInvitation::create([
            'tenant_id' => $tenant->id,
            'email' => $request->email,
            'role' => $request->role,
            'token' => Str::random(64),
            'expires_at' => now()->addDays(7),
        ]);

        // Davet maili gonder
        Mail::to($request->email)->send(new TeamInvitationMail($invitation));

        return back()->with('success', 'Davet gonderildi.');
    }

    public function acceptInvitation(string $token)
    {
        $invitation = TeamInvitation::where('token', $token)
            ->where('expires_at', '>', now())
            ->firstOrFail();

        $user = auth()->user();
        $user->update([
            'tenant_id' => $invitation->tenant_id,
        ]);

        // Rol ata
        $user->assignRole($invitation->role);

        $invitation->delete();

        return redirect()->route('dashboard')
            ->with('success', 'Takima katildiniz.');
    }
}

Yetkilendirme (Spatie Permissions)

composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate

Roller ve Izinler

// database/seeders/RolePermissionSeeder.php
class RolePermissionSeeder extends Seeder
{
    public function run()
    {
        // Izinler
        $permissions = [
            'projects.view', 'projects.create', 'projects.update', 'projects.delete',
            'invoices.view', 'invoices.create', 'invoices.update', 'invoices.delete',
            'team.view', 'team.invite', 'team.remove',
            'settings.view', 'settings.update',
            'billing.view', 'billing.manage',
        ];

        foreach ($permissions as $permission) {
            Permission::create(['name' => $permission]);
        }

        // Admin rolu - herseye yetkili
        $admin = Role::create(['name' => 'admin']);
        $admin->givePermissionTo(Permission::all());

        // Uye rolu
        $member = Role::create(['name' => 'member']);
        $member->givePermissionTo([
            'projects.view', 'projects.create', 'projects.update',
            'invoices.view',
            'team.view',
        ]);

        // Goruntuleyici rolu
        $viewer = Role::create(['name' => 'viewer']);
        $viewer->givePermissionTo([
            'projects.view',
            'invoices.view',
            'team.view',
        ]);
    }
}

Controller'da:

public function destroy(Project $project)
{
    $this->authorize('projects.delete');

    $project->delete();

    return back()->with('success', 'Proje silindi.');
}

Blade'de:

@can('projects.create')
    <a href="{{ route('projects.create') }}">Yeni Proje</a>
@endcan

Onboarding Akisi

Yeni kayit olan kullanici icin bir tenant olustur:

// app/Actions/CreateTenantAction.php
class CreateTenantAction
{
    public function execute(User $user, array $data): Tenant
    {
        return DB::transaction(function () use ($user, $data) {
            // Tenant olustur
            $tenant = Tenant::create([
                'name' => $data['company_name'],
                'slug' => Str::slug($data['company_name']),
                'owner_id' => $user->id,
                'plan' => 'free',
                'settings' => [
                    'timezone' => $data['timezone'] ?? 'Europe/Istanbul',
                    'locale' => 'tr',
                    'currency' => 'TRY',
                ],
            ]);

            // Kullaniciyi tenant'a bagla
            $user->update(['tenant_id' => $tenant->id]);
            $user->assignRole('admin');

            // Ornek veri olustur
            $this->createSampleData($tenant);

            return $tenant;
        });
    }

    protected function createSampleData(Tenant $tenant): void
    {
        Project::create([
            'name' => 'Ornek Proje',
            'description' => 'Bu bir ornek projedir. Duzenleyebilir veya silebilirsiniz.',
            'status' => 'active',
            'tenant_id' => $tenant->id,
        ]);
    }
}

Performans ve Olcekleme

Index'ler

// tenant_id icin her tabloda index ekle
Schema::table('projects', function (Blueprint $table) {
    $table->index(['tenant_id', 'status']);
    $table->index(['tenant_id', 'created_at']);
});

Eager Loading

// N+1 sorgu sorunu onleme
$projects = Project::with(['tasks', 'members', 'latestActivity'])
    ->paginate(15);

Cache Stratejisi

// Tenant bazli cache
class TenantCache
{
    public static function key(string $key): string
    {
        $tenantId = auth()->user()?->tenant_id ?? 'global';
        return "tenant_{$tenantId}_{$key}";
    }

    public static function remember(string $key, $ttl, Closure $callback)
    {
        return Cache::remember(static::key($key), $ttl, $callback);
    }

    public static function forget(string $key): void
    {
        Cache::forget(static::key($key));
    }
}

// Kullanim
$stats = TenantCache::remember('dashboard_stats', 3600, function () {
    return [
        'projects_count' => Project::count(),
        'active_tasks' => Task::where('status', 'active')->count(),
        'team_size' => auth()->user()->tenant->users()->count(),
    ];
});

Queue Isleri

Uzun suren islemler icin queue kullan:

// Tenant bilgisini job'a tasi
class GenerateMonthlyReport implements ShouldQueue
{
    public function __construct(
        public Tenant $tenant
    ) {}

    public function handle()
    {
        // Tenant scope'u elle ayarla (job'da auth yok)
        $projects = Project::withoutGlobalScopes()
            ->where('tenant_id', $this->tenant->id)
            ->get();

        // Rapor olustur...
    }
}

// Dispatch
GenerateMonthlyReport::dispatch($tenant);

Deploy Stratejisi

Ortam Degiskenleri

# Production
APP_ENV=production
APP_DEBUG=false

# Stripe
STRIPE_KEY=pk_live_...
STRIPE_SECRET=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...

# Queue
QUEUE_CONNECTION=redis

# Cache
CACHE_DRIVER=redis
SESSION_DRIVER=redis

# Mail
MAIL_MAILER=ses

Stripe Webhook

// routes/web.php
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handle'])
    ->middleware('stripe.webhook');
// app/Http/Controllers/StripeWebhookController.php
class StripeWebhookController extends Controller
{
    public function handle(Request $request)
    {
        $event = $request->all();

        match ($event['type']) {
            'customer.subscription.updated' => $this->handleSubscriptionUpdated($event),
            'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event),
            'invoice.payment_failed' => $this->handlePaymentFailed($event),
            default => null,
        };

        return response()->json(['status' => 'ok']);
    }

    protected function handleSubscriptionDeleted(array $event): void
    {
        $stripeId = $event['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeId)->first();

        if ($tenant) {
            $tenant->update(['plan' => 'free']);
            // Fazla kullanicilar icin bildirim gonder
        }
    }

    protected function handlePaymentFailed(array $event): void
    {
        $stripeId = $event['data']['object']['customer'];
        $tenant = Tenant::where('stripe_id', $stripeId)->first();

        if ($tenant) {
            $tenant->owner->notify(new PaymentFailedNotification());
        }
    }
}

Kontrol Listesi

SaaS uygulamani yayinlamadan once kontrol et:

  • Tenant izolasyonu test edildi (bir tenant baskasinin verisini goremiyor)
  • Plan limitleri calisiyor
  • Stripe webhook'lari test edildi
  • Abonelik iptal/degisiklik akislari calisiyor
  • Takim daveti ve rol atama calisiyor
  • Onboarding akisi tamamlandi
  • Rate limiting aktif
  • Cache stratejisi belirlendi
  • Queue calisiyor (email, rapor vb.)
  • Backup stratejisi var
  • Monitoring kuruldu (hata takibi)
  • Legal sayfalar var (gizlilik, kullanim kosullari)

Sonuc

Laravel ile SaaS gelistirmek dogru mimari kararlarla gayet verimli. Tek DB + tenant_id yaklasimi cogu proje icin yeterli. Cashier ile odeme, Spatie ile yetkilendirme, global scope'lar ile izolasyon -- hepsi Laravel ekosisteminde hazir.

Kucuk basla, MVP'ni cikar, kullanici geri bildirimlerine gore buyut. Multi-tenancy mimarisini en bastan dogru kurarsan, ileride olceklendirmek cok daha kolay olur.