Table of Contents
▼- Apa Itu Multi Tenant SaaS dan Mengapa Penting?
- Tiga Strategi Database Isolation untuk Multi Tenant
- Implementasi Tenant Identification Strategy
- Resource Limitation dan Quota Management
- Rate Limiting per Tenant
- Tenant Onboarding dan Provisioning
- Background Jobs dan Queue Management
- Monitoring dan Analytics per Tenant
- Security Best Practices untuk Multi Tenant
- Testing Strategy untuk Multi Tenant
- Performance Optimization Tips
- Kesimpulan
Membangun aplikasi SaaS (Software as a Service) multi-tenant adalah impian banyak developer dan startup digital di Indonesia.
Bayangkan satu aplikasi yang bisa melayani ratusan bahkan ribuan klien dengan data yang terisolasi sempurna, performa tetap optimal, dan biaya infrastruktur yang efisien.
Artikel ini akan memandu Anda membangun arsitektur multi-tenant SaaS yang scalable menggunakan Laravel, lengkap dengan strategi database isolation, resource management, dan best practices yang sudah terbukti di production.
Apa Itu Multi Tenant SaaS dan Mengapa Penting?
Multi-tenant architecture adalah pendekatan di mana satu instance aplikasi melayani multiple customers (tenants) dengan data yang terisolasi.
Setiap tenant memiliki data mereka sendiri yang tidak bisa diakses oleh tenant lain, namun semuanya berjalan di infrastruktur yang sama.
Keuntungan utama multi-tenant SaaS:
- Cost Efficiency - Satu server bisa melayani ratusan tenant, menghemat biaya infrastruktur hingga 70%
- Maintenance Simplicity - Update sekali langsung berlaku untuk semua tenant
- Resource Optimization - Sharing resource yang lebih efisien dibanding isolated deployment
- Faster Onboarding - Tenant baru bisa langsung aktif tanpa provisioning server baru
- Centralized Monitoring - Satu dashboard untuk monitor semua tenant
Contoh aplikasi multi-tenant SaaS sukses: Shopify, Slack, Salesforce, dan Mailchimp.
Tiga Strategi Database Isolation untuk Multi Tenant
Pilihan strategi database adalah keputusan arsitektural paling penting dalam membangun multi-tenant SaaS.
1. Single Database dengan Tenant ID (Shared Database)
Semua tenant menggunakan database yang sama, dengan kolom tenant_id di setiap tabel sebagai identifier.
Keuntungan:
- Paling mudah di-maintain dan monitor
- Query optimization berlaku untuk semua tenant
- Resource sharing maksimal
- Backup dan restore lebih sederhana
Kekurangan:
- Data leakage risk jika query tidak hati-hati
- Sulit memberikan custom schema per tenant
- Performance bottleneck jika ada tenant dengan traffic besar
Implementasi Laravel dengan Global Scope:
// app/Models/Scopes/TenantScope.php
namespace App\Models\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)
{
if (auth()->check() && auth()->user()->tenant_id) {
$builder->where('tenant_id', auth()->user()->tenant_id);
}
}
}
// app/Models/BaseModel.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use App\Models\Scopes\TenantScope;
abstract class BaseModel extends Model
{
protected static function booted()
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
if (auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
}2. Separate Database per Tenant
Setiap tenant memiliki database terpisah dengan schema yang sama atau custom.
Keuntungan:
- Isolasi data sempurna - zero risk data leakage
- Mudah untuk backup/restore per tenant
- Custom schema per tenant jika dibutuhkan
- Lebih mudah untuk migrasi tenant ke server terpisah
Kekurangan:
- Kompleksitas maintenance meningkat exponentially
- Migration harus dijalankan untuk semua database
- Query lintas tenant sangat sulit
- Resource overhead lebih besar
Implementasi dynamic database connection di Laravel:
// app/Services/TenantService.php
namespace App\Services;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
class TenantService
{
public static function setConnection($tenant)
{
$connectionName = 'tenant_' . $tenant->id;
Config::set('database.connections.' . $connectionName, [
'driver' => 'mysql',
'host' => env('DB_HOST', '127.0.0.1'),
'database' => 'tenant_' . $tenant->database_name,
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
]);
DB::purge($connectionName);
DB::setDefaultConnection($connectionName);
}
}
// Middleware untuk set connection
namespace App\Http\Middleware;
use Closure;
use App\Services\TenantService;
class SetTenantConnection
{
public function handle($request, Closure $next)
{
if ($tenant = $request->user()->tenant) {
TenantService::setConnection($tenant);
}
return $next($request);
}
}3. Hybrid Approach (Shared Schema + Separate Databases)
Database utama untuk tenant kecil, database terpisah untuk enterprise tenant dengan traffic besar.
Ini adalah sweet spot untuk SaaS yang melayani berbagai segmen customer.
Kesulitan dengan tugas programming atau butuh bantuan coding? KerjaKode siap membantu menyelesaikan tugas IT dan teknik informatika Anda. Dapatkan bantuan profesional di jasa tugas IT KerjaKode.
Implementasi Tenant Identification Strategy
Bagaimana aplikasi tahu request dari tenant mana? Ada tiga pendekatan umum:
1. Subdomain Based Identification
Format: tenant1.yourapp.com, tenant2.yourapp.com
// app/Http/Middleware/IdentifyTenant.php
namespace App\Http\Middleware;
use Closure;
use App\Models\Tenant;
class IdentifyTenant
{
public function handle($request, Closure $next)
{
$host = $request->getHost();
$subdomain = explode('.', $host)[0];
if ($subdomain === 'www' || $subdomain === config('app.domain')) {
return redirect()->route('landing');
}
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance('tenant', $tenant);
session(['tenant_id' => $tenant->id]);
return $next($request);
}
}2. Path Based Identification
Format: yourapp.com/tenant1/dashboard, yourapp.com/tenant2/dashboard
// routes/web.php
Route::prefix('{tenant}')->middleware('identify.tenant')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::resource('products', ProductController::class);
});3. Header Based Identification
Tenant ID dikirim via HTTP header, cocok untuk API:
// Middleware untuk API
public function handle($request, Closure $next)
{
$tenantId = $request->header('X-Tenant-ID');
if (!$tenantId) {
return response()->json(['error' => 'Tenant ID required'], 400);
}
$tenant = Tenant::findOrFail($tenantId);
app()->instance('tenant', $tenant);
return $next($request);
}Resource Limitation dan Quota Management
Setiap tenant harus memiliki limit resource untuk mencegah abuse dan memastikan fair usage.
Implementasi quota system:
// app/Models/Tenant.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Tenant extends Model
{
protected $fillable = [
'name', 'subdomain', 'plan_id',
'storage_limit', 'storage_used',
'user_limit', 'api_rate_limit'
];
public function canAddUser()
{
return $this->users()->count() user_limit;
}
public function hasStorageSpace($sizeInMB)
{
return ($this->storage_used + $sizeInMB) storage_limit;
}
public function incrementStorage($sizeInMB)
{
$this->increment('storage_used', $sizeInMB);
}
}
// Middleware untuk check quota
namespace App\Http\Middleware;
use Closure;
class CheckTenantQuota
{
public function handle($request, Closure $next)
{
$tenant = app('tenant');
// Check storage limit untuk file upload
if ($request->hasFile('file')) {
$fileSize = $request->file('file')->getSize() / 1048576; // Convert to MB
if (!$tenant->hasStorageSpace($fileSize)) {
return response()->json([
'error' => 'Storage limit exceeded'
], 403);
}
}
return $next($request);
}
}Rate Limiting per Tenant
Rate limiting penting untuk mencegah satu tenant menghabiskan resource dan mengganggu tenant lain.
// config/rate-limiting.php
return [
'plans' => [
'free' => ['requests' => 100, 'period' => 'hour'],
'basic' => ['requests' => 1000, 'period' => 'hour'],
'pro' => ['requests' => 10000, 'period' => 'hour'],
'enterprise' => ['requests' => 100000, 'period' => 'hour'],
]
];
// app/Http/Middleware/TenantRateLimit.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Support\Facades\RateLimiter;
class TenantRateLimit
{
public function handle($request, Closure $next)
{
$tenant = app('tenant');
$plan = config('rate-limiting.plans.' . $tenant->plan);
$key = 'tenant:' . $tenant->id;
$limit = $plan['requests'];
$decay = $plan['period'] === 'hour' ? 3600 : 60;
if (RateLimiter::tooManyAttempts($key, $limit)) {
return response()->json([
'error' => 'Rate limit exceeded',
'retry_after' => RateLimiter::availableIn($key)
], 429);
}
RateLimiter::hit($key, $decay);
$response = $next($request);
$response->headers->set('X-RateLimit-Limit', $limit);
$response->headers->set('X-RateLimit-Remaining',
$limit - RateLimiter::attempts($key));
return $response;
}
}Tenant Onboarding dan Provisioning
Proses onboarding tenant baru harus otomatis, cepat, dan reliable.
// app/Services/TenantProvisionService.php
namespace App\Services;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Artisan;
class TenantProvisionService
{
public function provision(array $data)
{
return DB::transaction(function () use ($data) {
// 1. Create tenant record
$tenant = Tenant::create([
'name' => $data['company_name'],
'subdomain' => $data['subdomain'],
'plan_id' => $data['plan_id'],
'storage_limit' => $this->getStorageLimit($data['plan_id']),
'user_limit' => $this->getUserLimit($data['plan_id']),
]);
// 2. Create database (jika separate database strategy)
if (config('app.tenant_database_strategy') === 'separate') {
$this->createTenantDatabase($tenant);
$this->runMigrations($tenant);
}
// 3. Create admin user
$admin = $tenant->users()->create([
'name' => $data['admin_name'],
'email' => $data['admin_email'],
'password' => bcrypt($data['password']),
'role' => 'admin',
]);
// 4. Seed initial data
$this->seedInitialData($tenant);
// 5. Send welcome email
$admin->sendWelcomeEmail();
return $tenant;
});
}
private function createTenantDatabase($tenant)
{
$dbName = 'tenant_' . $tenant->id;
DB::statement("CREATE DATABASE `{$dbName}`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci");
$tenant->update(['database_name' => $dbName]);
}
private function runMigrations($tenant)
{
TenantService::setConnection($tenant);
Artisan::call('migrate', ['--force' => true]);
}
}Background Jobs dan Queue Management
Queue jobs harus aware tentang tenant context untuk menghindari data leakage.
// app/Jobs/TenantAwareJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use App\Services\TenantService;
use App\Models\Tenant;
abstract class TenantAwareJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;
protected $tenantId;
public function __construct($tenantId = null)
{
$this->tenantId = $tenantId ?? app('tenant')->id;
}
public function handle()
{
$tenant = Tenant::findOrFail($this->tenantId);
TenantService::setConnection($tenant);
$this->handleJob();
}
abstract protected function handleJob();
}
// Contoh penggunaan
namespace App\Jobs;
class GenerateMonthlyReport extends TenantAwareJob
{
protected function handleJob()
{
// Logic untuk generate report
// Semua query otomatis menggunakan tenant connection
$orders = Order::whereMonth('created_at', now()->month)->get();
// Process and send report...
}
}Monitoring dan Analytics per Tenant
Monitoring performa per tenant penting untuk identify bottleneck dan optimize resource allocation.
// app/Services/TenantMetricsService.php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class TenantMetricsService
{
public function recordMetric($tenant, $metric, $value)
{
$key = "metrics:{$tenant->id}:{$metric}:" . now()->format('Y-m-d-H');
Cache::increment($key, $value);
Cache::expire($key, 86400 * 30); // Keep 30 days
}
public function getMetrics($tenant, $metric, $hours = 24)
{
$metrics = [];
for ($i = 0; $i subHours($i);
$key = "metrics:{$tenant->id}:{$metric}:" . $time->format('Y-m-d-H');
$metrics[$time->format('Y-m-d H:00')] = Cache::get($key, 0);
}
return $metrics;
}
public function getDatabaseSize($tenant)
{
$dbName = $tenant->database_name;
$size = DB::select("
SELECT
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) as size_mb
FROM information_schema.TABLES
WHERE table_schema = ?
", [$dbName]);
return $size[0]->size_mb ?? 0;
}
}Security Best Practices untuk Multi Tenant
Security adalah prioritas utama dalam multi-tenant architecture.
Principle 1: Never Trust Client Input
Selalu validasi tenant_id dari server-side, jangan percaya dari input atau cookie.
Principle 2: Use Global Scopes
Implement global scope di level model untuk automatic tenant filtering.
Principle 3: Audit Trails
// app/Models/Traits/HasAuditTrail.php
namespace App\Models\Traits;
trait HasAuditTrail
{
protected static function bootHasAuditTrail()
{
static::created(function ($model) {
activity()
->performedOn($model)
->withProperties(['tenant_id' => app('tenant')->id])
->log('created');
});
static::updated(function ($model) {
activity()
->performedOn($model)
->withProperties([
'tenant_id' => app('tenant')->id,
'old' => $model->getOriginal(),
'new' => $model->getAttributes(),
])
->log('updated');
});
}
}Principle 4: Encryption at Rest
Encrypt sensitive data per tenant dengan unique encryption keys.
Testing Strategy untuk Multi Tenant
Testing multi-tenant aplikasi memerlukan pendekatan khusus.
// tests/Feature/MultiTenantTest.php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class MultiTenantTest extends TestCase
{
use RefreshDatabase;
public function test_tenant_isolation()
{
$tenant1 = Tenant::factory()->create();
$tenant2 = Tenant::factory()->create();
$user1 = User::factory()->for($tenant1)->create();
$user2 = User::factory()->for($tenant2)->create();
$this->actingAs($user1);
$product1 = Product::factory()->create();
$this->actingAs($user2);
$product2 = Product::factory()->create();
// Verify tenant 1 cannot see tenant 2's products
$this->actingAs($user1);
$this->assertCount(1, Product::all());
$this->assertTrue(Product::all()->contains($product1));
$this->assertFalse(Product::all()->contains($product2));
}
}Performance Optimization Tips
Beberapa optimasi penting untuk multi-tenant SaaS:
- Connection Pooling - Reuse database connection antar request
- Query Caching - Cache query result per tenant dengan proper invalidation
- CDN untuk Assets - Serve static assets via CDN dengan tenant-specific cache keys
- Redis untuk Session - Gunakan Redis dengan tenant prefix untuk session storage
- Lazy Loading - Load tenant configuration on-demand, bukan di setiap request
- Database Indexing - Pastikan tenant_id column ter-index dengan baik
Kesimpulan
Membangun multi-tenant SaaS yang scalable dengan Laravel memerlukan perencanaan arsitektural yang matang.
Pilih strategi database isolation yang sesuai dengan business model Anda: shared database untuk simplicity, separate database untuk isolation, atau hybrid untuk flexibility.
Implement tenant identification yang robust, quota management yang ketat, dan security best practices di setiap layer.
Dengan arsitektur yang tepat, Anda bisa melayani ribuan tenant dengan performa optimal dan biaya infrastruktur yang efisien.
Multi-tenant SaaS bukan hanya tentang technology stack, tapi juga tentang bagaimana Anda mengelola resource, maintain isolation, dan deliver value kepada setiap tenant secara konsisten.
Mulai dengan sederhana, test securely, dan scale gradually seiring pertumbuhan customer base Anda.