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.