· Admin

Laravel ile SaaS Uygulama Geliştirme: Multi-Tenancy'den Billing'e

SaaS (Software as a Service) uygulama gelistirmek, standart bir web uygulamasi yazmaktan cok farkli bir disiplin gerektiriyor. Tek bir codebase uzerinden onlarca, yuzlerce hatta binlerce musteriye hizmet verirken; veri izolasyonu, faturalama, onboarding ve plan bazli erisim gibi konularin her birini dogru planlamak gerekiyor. Bu makalede kendi SaaS projelerimden edindigim deneyimleri ve Laravel ekosisteminin sundugu araclari paylasiyorum.

Multi-Tenancy: Hangi Yaklasimi Secmelisiniz?

Multi-tenancy konusunda temel olarak uc yaklasim var ve her birinin trade-off'lari farkli.

1. Single Database, Shared Schema (tenant_id)

En yaygin ve en basit yaklasim. Tum tenant'lar ayni tablolari paylasiyor, her kayitta bir tenant_id kolonu bulunuyor.

// Migration
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->string('name');
    $table->text('description')->nullable();
    $table->timestamps();

    $table->index('tenant_id');
});

Bu yaklasimda en kritik konu, her sorgunun mutlaka tenant_id ile filtrelenmesi. Bunun icin bir global scope tanimliyorum:

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

// app/Models/Concerns/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function (Model $model) {
            if (auth()->check()) {
                $model->tenant_id = auth()->user()->tenant_id;
            }
        });
    }

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

Kendi SaaS projemde single-database + tenant_id yaklasimini tercih ettim. Sebebi basit: altyapi maliyeti dusuk, migration yonetimi kolay ve kucuk-orta olcekli SaaS icin fazlasiyla yeterli.

2. Single Database, Schema Per Tenant

PostgreSQL kullaniyorsaniz her tenant icin ayri bir schema olusturabilirsiniz. Veri izolasyonu daha guclu ama migration'lari her schema icin ayri calistirmaniz gerekiyor.

3. Database Per Tenant

Her tenant icin ayri bir veritabani. En guclu izolasyon ama operasyonel karmasiklik en yuksek seviyede. Buyuk enterprise musterileriniz varsa ve veri izolasyonu yasal zorunluluksa bu yolu secebilirsiniz.

Benim onerim: 100 tenant'a kadar single DB + tenant_id ile baslayip, ihtiyac oldukca evolve etmek.

Tenant Isolation: Middleware ile Guvenlik

Tenant izolasyonunu sadece global scope'a birakmak riskli. Middleware katmaninda da kontrol etmek gerekiyor.

// app/Http/Middleware/EnsureTenantAccess.php
class EnsureTenantAccess
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! auth()->check() || ! auth()->user()->tenant_id) {
            abort(403, 'Tenant erisimi gerekli.');
        }

        // Tenant context'i app container'a bind et
        app()->instance('current_tenant', auth()->user()->tenant);

        return $next($request);
    }
}

Route gruplariyla birlikte kullandigimizda:

// routes/web.php
Route::middleware(['auth', 'tenant'])->group(function () {
    Route::resource('projects', ProjectController::class);
    Route::resource('invoices', InvoiceController::class);
});

Boylece hem authentication hem tenant erisimi garanti altina alinmis oluyor.

Subscription ve Billing: Laravel Cashier

SaaS'in kalbi faturalama. Laravel Cashier, Stripe entegrasyonunu inanilmaz kolaylastiriyor.

composer require laravel/cashier
php artisan migrate

Tenant modelini Billable trait ile donatin:

use Laravel\Cashier\Billable;

class Tenant extends Model
{
    use Billable;

    public function onFreePlan(): bool
    {
        return ! $this->subscribed('default');
    }

    public function onPlan(string $plan): bool
    {
        return $this->subscribedToPrice($plan, 'default');
    }
}

Subscription olusturma islemini bir controller ile yonetiyorum:

class SubscriptionController extends Controller
{
    public function store(Request $request)
    {
        $tenant = auth()->user()->tenant;

        $tenant->newSubscription('default', $request->price_id)
            ->trialDays(14)
            ->create($request->payment_method);

        return redirect()->route('dashboard')
            ->with('success', 'Abonelik basariyla olusturuldu!');
    }

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

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

Stripe webhook'larini dinlemeyi unutmayin. customer.subscription.updated, invoice.payment_failed gibi event'ler kritik.

Onboarding Flow

Ilk kullanici deneyimi SaaS'in basarisini dogrudan etkiliyor. Benim kullandigim onboarding pattern'i su adimlari iceriyor:

// app/Http/Middleware/EnsureOnboardingComplete.php
class EnsureOnboardingComplete
{
    public function handle(Request $request, Closure $next): Response
    {
        $tenant = auth()->user()->tenant;

        if (! $tenant->onboarding_completed_at) {
            return redirect()->route('onboarding.index');
        }

        return $next($request);
    }
}

Onboarding controller'i adim adim ilerliyor:

class OnboardingController extends Controller
{
    public function index()
    {
        $tenant = auth()->user()->tenant;
        $step = $this->getCurrentStep($tenant);

        return view("onboarding.step-{$step}", compact('tenant'));
    }

    private function getCurrentStep(Tenant $tenant): int
    {
        if (! $tenant->company_name) return 1;      // Sirket bilgileri
        if (! $tenant->team_members_count) return 2; // Takim boyutu
        if (! $tenant->industry) return 3;           // Sektor secimi
        return 4;                                     // Tamamlandi
    }
}

Plan Bazli Erisim ve Feature Flags

Her planin farkli ozelliklere erisimi olmasi SaaS'in dogasi geregi. Bunu yonetmek icin basit bir feature flag sistemi kurdum:

// config/plans.php
return [
    'starter' => [
        'max_projects' => 5,
        'max_users' => 3,
        'features' => ['basic_reports'],
    ],
    'professional' => [
        'max_projects' => 50,
        'max_users' => 20,
        'features' => ['basic_reports', 'advanced_reports', 'api_access'],
    ],
    'enterprise' => [
        'max_projects' => -1, // sinirsiz
        'max_users' => -1,
        'features' => ['basic_reports', 'advanced_reports', 'api_access', 'custom_branding', 'sso'],
    ],
];

Tenant modelinde kontrol metotlari:

class Tenant extends Model
{
    public function hasFeature(string $feature): bool
    {
        $plan = $this->currentPlan();
        $features = config("plans.{$plan}.features", []);

        return in_array($feature, $features);
    }

    public function canCreateProject(): bool
    {
        $plan = $this->currentPlan();
        $max = config("plans.{$plan}.max_projects");

        if ($max === -1) return true;

        return $this->projects()->count() < $max;
    }

    public function currentPlan(): string
    {
        if ($this->onFreePlan()) return 'starter';

        // Stripe price ID'ye gore plan belirle
        return match ($this->subscription('default')?->stripe_price) {
            config('stripe.prices.professional') => 'professional',
            config('stripe.prices.enterprise') => 'enterprise',
            default => 'starter',
        };
    }
}

Blade tarafinda kullanimi:

@if(auth()->user()->tenant->hasFeature('advanced_reports'))
    <a href="{{ route('reports.advanced') }}">Gelismis Raporlar</a>
@endif

@if(auth()->user()->tenant->canCreateProject())
    <button>Yeni Proje</button>
@else
    <p>Proje limitinize ulastiniz. Planunuzu yukseltmek icin tiklayin.</p>
@endif

SaaS Icin Ek Ipuclari

Queue ve Job'lar tenant-aware olmali. Job icinde tenant context'ini kaybedemezsiniz:

class GenerateReport implements ShouldQueue
{
    public function __construct(
        public readonly int $tenantId,
        public readonly string $reportType,
    ) {}

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);

        // Tenant scope'u manuel set et
        app()->instance('current_tenant', $tenant);

        // Rapor olustur...
    }
}

Rate limiting tenant bazli olmali:

// AppServiceProvider boot
RateLimiter::for('api', function (Request $request) {
    $tenant = $request->user()?->tenant;
    $limit = match ($tenant?->currentPlan()) {
        'enterprise' => 1000,
        'professional' => 300,
        default => 60,
    };

    return Limit::perMinute($limit)->by($tenant?->id ?: $request->ip());
});

Sonuc

SaaS gelistirmek maraton gibi bir is. Dogru temeli attiginizda ustune hizla insa edebilirsiniz. Single database + tenant_id ile baslayip, Cashier ile billing'i entegre edip, feature flag'lerle plan bazli erisimi yonetin. Onboarding flow'u ihmal etmeyin cunku musterinin ilk 5 dakikasi her seyi belirliyor. Benim SaaS projelerimde bu pattern'lerin her birini kullaniyorum ve gayet iyi calisiyor.