Laravel’de Çok Kiracılı (Multi-Tenant) Yapı: Tek Veritabanı ile Tenant İzolasyonu
Tek veritabanında tenant izolasyonu: global scope, middleware ve bağlantı stratejileriyle pratik multi-tenant kurulum.
Neden Multi-Tenant?
SaaS geliştiriyorsanız aynı uygulamayı birden fazla müşteri (tenant) kullanır. Multi-tenant mimaride amaç; veriyi karıştırmadan (izolasyon), performansı düşürmeden ve güvenliği artırarak müşterileri tek uygulamada yönetmektir.
Bu yazıda popüler başlıklardan farklı olarak; tek veritabanı + tenant_id yaklaşımını Laravel’de “kazaya kapalı” hale getiren pratik bir iskelet kuracağız.
Model: Tenant ve tenant_id standardı
En basit senaryo: tüm tenant verileri aynı DB’de, tablolar tenant_id ile ayrılır.
// database/migrations/xxxx_create_tenants.php
Schema::create('tenants', function ($table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Uygulama tablolarınıza tenant_id ekleyin:
Schema::table('projects', function ($table) {
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->index(['tenant_id']);
});
Tenant çözümleme: Subdomain ile tenant seçmek
Örnek: acme.app.com → tenant slug = acme
// routes/web.php
Route::middleware(['web', 'tenant.resolve'])->group(function () {
Route::get('/projects', fn () => \App\Models\Project::latest()->paginate());
});
Middleware:
// app/Http/Middleware/ResolveTenant.php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
class ResolveTenant
{
public function handle($request, Closure $next)
{
$host = $request->getHost();
$subdomain = explode('.', $host)[0] ?? null;
$tenant = Tenant::where('slug', $subdomain)->firstOrFail();
app()->instance('tenant', $tenant); // basit tenant konteyneri
return $next($request);
}
}
Kernel.php içine ekleyin:
protected $routeMiddleware = [
'tenant.resolve' => \App\Http\Middleware\ResolveTenant::class,
];
En kritik parça: Global Scope ile otomatik filtre
İnsan hatasını azaltmanın yolu: sorguların tenant filtresini otomatik eklemek.
Bir trait oluşturalım:
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (app()->bound('tenant')) {
$builder->where($builder->getModel()->getTable().'.tenant_id', app('tenant')->id);
}
});
static::creating(function ($model) {
if (app()->bound('tenant') && empty($model->tenant_id)) {
$model->tenant_id = app('tenant')->id;
}
});
}
}
Model’de kullanın:
class Project extends Model
{
use \App\Models\Concerns\BelongsToTenant;
protected $fillable = ['name'];
}
Artık Project::query() tenant filtresi olmadan “yanlışlıkla” başka tenant verisi döndüremez.
Yönetici ekranında tüm tenant verilerini görmek isterseniz:
Project::withoutGlobalScope('tenant')->...
Güvenlik notu: Route Model Binding tuzağı
/projects/{project} gibi rotalarda model binding tenant scope’u sayesinde otomatik filtrelenir. Ancak bazı durumlarda scope devre dışı kalırsa risk oluşur. Bu yüzden global scope yaklaşımı kritik.
Ek önlem: Policy ile tenant kontrolü.
public function view($user, Project $project)
{
return $project->tenant_id === app('tenant')->id;
}
Performans: Index ve cache
- Tablolarda
tenant_idindex’i şart. - Tenant çözümlemesini cache’leyin:
$tenant = cache()->remember(
'tenant:'.$subdomain,
now()->addMinutes(10),
fn() => Tenant::whereSlug($subdomain)->firstOrFail()
);
Sonuç
Tek veritabanında multi-tenant yapı kurarken amaç “çalışması” değil, yanlış çalışmamasıdır. ResolveTenant + Global Scope + creating hook kombinasyonu; hem geliştirici ergonomisini artırır hem de veri izolasyonunu varsayılan hale getirir.
Bir sonraki adım olarak; tenant’a özel dosya depolama (Storage::disk) ve job/queue izolasyonunu (örn. tenant_id ile) aynı mantıkla standardize edebilirsiniz.