Semua aplikasi yang kita bangun sepanjang course ini melayani satu pemilik data. Sekarang bayangkan satu aplikasi Laravel yang sama — satu kode, satu deployment — dipakai dua ratus kedai kopi, masing-masing hanya boleh melihat datanya sendiri. Arsitektur semacam itu disebut multi-tenancy, dan keputusan awalnya (bagaimana tenant diidentifikasi, bagaimana data dipisah) sangat sulit diubah belakangan. Chapter ini memetakan pilihan arsitekturnya secara jujur, lalu membangun demo kecil strategi termurah: kolom tenant_id plus global scope.
Satu Aplikasi, Banyak Penyewa #
Tenant adalah pelanggan aplikasi — satu perusahaan, satu toko, satu tim. Semua tenant memakai kode yang sama, tetapi data, pengguna, dan konfigurasi masing-masing terpisah. Hal pertama yang harus diputuskan adalah cara request dihubungkan ke tenant:
| Cara identifikasi | Bentuknya | Cocok untuk |
|---|---|---|
| Subdomain | kopi-budi.app.test, kopi-siti.app.test | Aplikasi SaaS dengan branding per pelanggan |
| Path | app.test/kopi-budi/... | Portal yang berbagi satu domain |
| Header / API key | X-Tenant: kopi-budi | API murni tanpa UI, integrasi machine-to-machine |
Ketiganya hanya berbeda cara mengambil identitas dari request; setelah identitas diketahui, mekanisme pemisahan datanya sama.
Tiga Strategi Isolasi Database #
Pilihan arsitektur utama ada di database: satu database untuk semua tenant, atau satu database per tenant. Berikut perbandingan jujur ketiga strategi:
| Aspek | Satu DB + kolom tenant_id | Satu DB per tenant | Campuran |
|---|---|---|---|
| Kompleksitas awal | Rendah — migrasi biasa | Tinggi — koneksi dinamis, migrasi N kali | Sedang–tinggi |
| Backup per pelanggan | Perlu query eksport | Trivial: satu file | Campur keduanya |
| Migrasi skema | Sekali jalan | Harus diulang ke semua DB | Sekali + N |
| Risiko data bocor antar tenant | Ada bila scope lupa dipasang | Nyaris nol secara struktur | Bervariasi |
| Biaya infrastruktur | Satu server DB | Bertambah tiap tenant (atau shared server) | Tengah |
Strategi koneksi dinamis untuk multi-database memakai config/database.php yang diisi saat runtime lalu DB::purge() untuk memutus koneksi lama (contoh tampilan):
config(['database.connections.tenant' => [
'driver' => 'sqlite',
'database' => database_path("tenants/{$tenant->slug}.sqlite"),
]]);
DB::purge('tenant'); // paksa koneksi baru dibangun pada query berikutnya
Untuk course ini kita pilih strategi pertama dan membangunnya sungguhan.
Demo: Kolom tenant_id plus Global Scope #
Bahan bakunya dua migrasi: tabel tenants (id, nama, slug unik) dan tabel tasks dengan foreign key tenant_id. Sebuah class konteks menyimpan tenant aktif untuk request berjalan, dan middleware mengisinya dari header:
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Tenancy\TenantContext;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function __construct(
protected readonly TenantContext $context,
) {}
public function handle(Request $request, Closure $next): Response
{
$slug = (string) $request->header('X-Tenant', '');
$tenant = Tenant::query()->where('slug', $slug)->first();
if (is_null($tenant)) {
abort(400, 'Tenant tidak dikenal: '.$slug);
}
$this->context->set($tenant);
return $next($request);
}
}Inti isolasinya ada di model Task, lewat global scope yang sudah kita kenal di chapter Query Scopes
:
protected static function booted(): void
{
// Global scope: setiap query Task otomatis difilter tenant aktif.
static::addGlobalScope('tenant', function (Builder $builder) {
$tenant = app(TenantContext::class)->current();
if (! is_null($tenant)) {
$builder->where('tasks.tenant_id', $tenant->id);
}
});
}Setiap Task::query() kini otomatis menyisipkan where tasks.tenant_id = ? — controller bahkan tidak perlu tahu multi-tenancy ada. Feature test berikut menyemai dua tenant dengan data berbeda lalu memanggil route /tugas dua kali:
Output yang Dihasilkan:
budi : 2 tugas: Tulis laporan, Kirim invoice
siti : 1 tugas: Racik kopi
tanpa : status=400Budi hanya melihat dua tugasnya, Siti hanya melihat satu tugasnya, dan request tanpa identitas tenant ditolak di depan pintu. Seluruh mekanisme ini hanya dua puluh baris kode — inilah kenapa strategi kolom menjadi titik awal default.
Bagian yang Sulit: Cache, Queue, Session #
Database bukan satu-satunya tempat tenant bisa saling menabrak. Cache, queue, dan session umumnya berbagi satu store, dan key stok milik Budi akan menimpa stok milik Siti bila tidak diawali. Solusinya sederhana tapi wajib disiplin: awali setiap key dengan identitas tenant. Kita tambahkan method prefix() ke TenantContext dan uji perilakunya:
Output yang Dihasilkan:
prefix budi : tenant:1:stok
prefix siti : tenant:2:stok
nilai budi : arabika
nilai siti : (kosong)Key yang sama kini hidup berdampingan tanpa saling menimpa. Queue punya masalah sejenis: job yang dieksekusi worker harus membawa identitas tenant dan mengembalikannya ke konteks saat job berjalan, karena worker tidak mewarisi request. Session lebih mudah — cookie per subdomain sudah terisolasi alami.
Ekosistem dan Kapan Harus Menolak #
Membangun semua sendiri valid untuk belajar, tetapi ekosistem punya paket standar de facto: stancl/tenancy — identifikasi tenant, database per tenant, storage, cache, dan queue sadar-tenant dalam satu kesatuan. Pertimbangkan begitu kebutuhan melampaui demo chapter ini. Meski begitu, multi-tenancy bukan kemenangan tanpa syarat. Tolak dulu bila: jumlah pelanggan masih segelintir (kompleksitas belum terbayar), setiap pelanggan minta deployment terpisah (instalasi per pelanggan lebih murah dan lebih aman), atau tim belum siap mengoperasikan komponen stateful bersama. Arsitektur paling mudah dioperasikan hari ini sering menang cepat dibanding yang paling canggih.
Dengan data kini bisa terbagi rapi antar pelanggan, pertanyaan penutup section ini bergeser ke fondasi: bagaimana merancang aplikasi yang bisa tumbuh — menerima integrasi baru, fitur baru, pelanggan baru — tanpa membongkar fondasinya. Itulah chapter terakhir, Extensible Application Design.