Bagian 1: Pendahuluan - Apa itu Redis dan Mengapa Website Asuransi Membutuhkannya
Redis adalah in-memory data store yang bisa meningkatkan performa aplikasi Laravel secara drastis, terutama untuk website asuransi jiwa yang membutuhkan kalkulasi premi cepat, session management untuk ribuan user, dan caching data produk yang sering diakses. Dengan Redis, response time yang tadinya 100-200ms bisa turun menjadi kurang dari 5ms, memberikan pengalaman user yang jauh lebih baik dan mengurangi load server secara signifikan.
Halo teman-teman developer! Saya Angga Risky Setiawan, founder dari BuildWithAngga. Beberapa waktu lalu saya bekerja dengan sebuah perusahaan asuransi yang sedang membangun platform digital untuk penjualan polis secara online. Aplikasinya sudah jadi dan fiturnya lengkap, tapi ada satu masalah besar. Setiap kali calon nasabah membuka halaman produk atau menggunakan kalkulator premi, mereka harus menunggu 2-3 detik untuk melihat hasilnya.
Setelah saya investigasi, ternyata masalahnya klasik. Setiap request ke halaman produk memicu query ke database untuk mengambil data yang sebenarnya jarang berubah. Kalkulator premi harus lookup ke tabel rate dengan ratusan baris setiap kali nasabah mengubah parameter. Bayangkan jika ada 1000 calon nasabah mengakses kalkulator dalam satu jam, itu artinya ribuan query untuk data yang identik. Database server jadi overloaded dan response time membengkak.
Solusinya adalah Redis.
Biar saya jelaskan dengan analogi sederhana. Bayangkan kantor asuransi dengan lemari arsip besar yang menyimpan semua dokumen polis, data nasabah, dan tabel rate premi. Lemari arsip ini adalah database kalian, entah itu MySQL atau PostgreSQL. Semua data tersimpan dengan rapi dan aman, tapi setiap kali butuh informasi, petugas harus jalan ke lemari, cari dokumen yang tepat, dan bawa kembali ke meja. Proses ini butuh waktu.
Sekarang bayangkan ada meja kerja di samping petugas. Di meja ini, petugas meletakkan dokumen-dokumen yang sedang sering dipakai. Tabel rate premi yang diakses puluhan kali sehari, brosur produk yang selalu diminta calon nasabah, formulir-formulir standar. Ketika ada yang butuh dokumen ini, petugas tinggal ambil dari meja dalam hitungan detik, tidak perlu jalan ke lemari arsip.
Redis adalah meja kerja itu. Data yang sering diakses disimpan di memory (RAM) sehingga bisa diambil dalam waktu kurang dari 1 milidetik. Jika meja dibersihkan atau komputer restart, dokumen di meja memang hilang, tapi itu tidak masalah karena kita selalu bisa ambil lagi dari lemari arsip (database) dan letakkan kembali di meja.
Redis memiliki beberapa karakteristik yang membuatnya sangat powerful. Pertama, Redis menyimpan data di memory bukan di disk, sehingga operasi baca tulis sangat cepat. Kedua, Redis mendukung berbagai struktur data seperti strings, hashes, lists, sets, dan sorted sets yang memungkinkan berbagai use case. Ketiga, Redis bisa digunakan tidak hanya sebagai cache, tapi juga sebagai session store, message broker, dan queue backend. Keempat, Redis memiliki fitur expiration yang memungkinkan data otomatis terhapus setelah waktu tertentu, sangat berguna untuk cache yang harus di-refresh berkala.
Untuk website asuransi jiwa, ada banyak skenario di mana Redis bisa memberikan improvement signifikan.
Caching data produk adalah use case yang paling obvious. Website asuransi biasanya punya beberapa produk seperti Term Life, Whole Life, dan Unit Link. Data produk ini termasuk deskripsi, fitur, ketentuan, dan dokumen pendukung diakses ribuan kali per hari oleh calon nasabah yang browsing. Tapi data ini hanya diupdate beberapa kali sebulan oleh tim product. Tidak masuk akal untuk query database setiap kali ada yang buka halaman produk. Dengan Redis, query pertama disimpan di cache dan semua request berikutnya langsung diambil dari memory.
Tabel rate premi adalah contoh lain yang sangat cocok untuk Redis. Kalkulator premi adalah fitur yang membuat calon nasabah tertarik karena mereka bisa langsung tahu berapa yang harus dibayar. Tapi di balik layar, kalkulasi premi melibatkan lookup ke tabel rate yang kompleks berdasarkan usia, jenis kelamin, jenis produk, dan jumlah coverage. Tabel ini bisa berisi ratusan bahkan ribuan baris untuk semua kombinasi. Dengan menyimpan tabel rate di Redis sebagai hash table, lookup menjadi operasi O(1) yang instan.
Session management adalah area di mana Redis sangat membantu untuk skalabilitas. Ketika nasabah login ke dashboard untuk cek status polis atau ajukan klaim, Laravel perlu menyimpan session data. Secara default, Laravel menyimpan session di file. Ini bermasalah jika aplikasi di-scale ke multiple server karena session di server A tidak bisa diakses dari server B. Dengan Redis sebagai session store, semua server bisa mengakses session yang sama karena Redis adalah centralized storage.
Queue untuk background processing adalah use case penting untuk aplikasi asuransi. Ketika nasabah submit pengajuan polis, ada banyak proses yang harus dilakukan seperti validasi data, pengecekan dokumen, proses underwriting, dan pengiriman email konfirmasi. Jika semua ini dilakukan synchronous, nasabah harus menunggu lama. Dengan Redis sebagai queue backend, task-task berat ini di-push ke queue dan diproses di background. Response ke nasabah bisa langsung kembali dalam hitungan milidetik dengan pesan "Pengajuan Anda sedang diproses".
Rate limiting untuk proteksi API juga sangat relevan. API kalkulator premi adalah target empuk untuk scraping oleh kompetitor atau abuse oleh bot. Dengan Redis, kita bisa implement rate limiting yang efisien untuk membatasi berapa kali sebuah IP atau user bisa mengakses endpoint tertentu dalam periode waktu tertentu.
Real-time notification untuk status pengajuan bisa diimplementasikan dengan Redis Pub/Sub. Ketika status pengajuan polis berubah dari "reviewing" menjadi "approved", nasabah bisa langsung mendapat notifikasi tanpa harus refresh halaman. Redis menjadi backbone untuk fitur real-time ini.
Tanpa Redis, setiap interaksi user dengan website menghasilkan query ke database. Jika ada 1000 calon nasabah membuka halaman produk yang sama dalam satu jam, itu 1000 query identik yang membebani database. Jika ada 500 orang menggunakan kalkulator premi dengan rata-rata 5 kali coba per orang, itu 2500 lookup ke tabel rate. Database server yang tadinya santai jadi kerja keras, response time naik, dan user experience menurun.
Dengan Redis, query pertama ke database untuk data produk disimpan di cache. 999 request berikutnya langsung diambil dari Redis dalam waktu kurang dari 1 milidetik. Tabel rate premi di-load sekali ke Redis dan semua kalkulasi menggunakan data di memory. Database server tetap santai, response time konsisten cepat, dan nasabah happy.
Dalam tutorial ini, kita akan membangun website AsuransiKu, sebuah platform asuransi jiwa digital dengan fitur landing page produk, kalkulator premi, pendaftaran polis online, dashboard nasabah, dan portal agen. Kita akan mengimplementasikan Redis untuk semua use case yang sudah saya sebutkan mulai dari caching, session, queue, rate limiting, hingga real-time notification.
Di bagian selanjutnya, kita akan mulai dengan instalasi Redis dan konfigurasi Laravel untuk menggunakan Redis. Saya akan tunjukkan step-by-step cara setup di berbagai sistem operasi dan memastikan Laravel bisa berkomunikasi dengan Redis dengan benar.
Siapkan kopi dan laptop kalian. Kita akan membuat website asuransi yang tidak hanya fungsional tapi juga blazingly fast.
Bagian 2: Instalasi dan Konfigurasi Redis di Laravel
Instalasi Redis melibatkan dua komponen utama yaitu Redis server yang berjalan sebagai service di sistem operasi dan PHP package yang memungkinkan Laravel berkomunikasi dengan Redis. Tutorial ini mencakup instalasi di Ubuntu, MacOS, dan Docker, serta konfigurasi Laravel untuk menggunakan Redis sebagai cache, session, dan queue driver dengan connection terpisah untuk isolasi yang lebih baik.
Sebelum mulai coding fitur-fitur keren dengan Redis, kita perlu memastikan environment sudah siap. Ada dua hal yang harus di-install yaitu Redis server itu sendiri dan package PHP agar Laravel bisa berkomunikasi dengan Redis. Proses ini cukup straightforward tapi saya akan jelaskan detail untuk berbagai sistem operasi.
Untuk pengguna Ubuntu atau Debian, instalasi Redis sangat mudah melalui package manager. Buka terminal dan jalankan command berikut.
# Update package list
sudo apt update
# Install Redis server
sudo apt install redis-server -y
# Start Redis service
sudo systemctl start redis-server
# Enable Redis untuk start otomatis saat boot
sudo systemctl enable redis-server
# Verifikasi Redis sudah berjalan
sudo systemctl status redis-server
Setelah instalasi, verifikasi Redis bisa menerima koneksi dengan command ping.
redis-cli ping
Jika Redis berjalan dengan benar, response yang muncul adalah PONG. Ini menandakan Redis server sudah siap menerima command.
Untuk pengguna MacOS, cara termudah adalah menggunakan Homebrew. Jika belum punya Homebrew, install dulu dari website resminya.
# Install Redis via Homebrew
brew install redis
# Start Redis service
brew services start redis
# Verifikasi
redis-cli ping
Untuk pengguna Windows, Redis tidak officially support Windows. Ada dua opsi yang saya rekomendasikan. Pertama adalah menggunakan WSL2 (Windows Subsystem for Linux) dan install Redis di dalamnya seperti cara Ubuntu di atas. Kedua adalah menggunakan Docker yang lebih portable dan tidak mengotori sistem.
Docker adalah opsi yang saya rekomendasikan untuk development karena mudah di-setup dan bisa dihapus tanpa jejak. Berikut cara menjalankan Redis dengan Docker.
# Pull Redis image
docker pull redis
# Jalankan Redis container
docker run --name redis-asuransiku -p 6379:6379 -d redis
# Verifikasi container berjalan
docker ps
# Test koneksi (dari host)
redis-cli ping
Jika ingin Redis dengan password untuk keamanan tambahan, gunakan command berikut.
docker run --name redis-asuransiku -p 6379:6379 -d redis redis-server --requirepass rahasia123
Sekarang Redis server sudah berjalan. Langkah selanjutnya adalah menginstall package PHP agar Laravel bisa berkomunikasi dengan Redis. Ada dua pilihan yaitu predis dan phpredis.
Predis adalah library PHP murni yang tidak memerlukan compile extension. Instalasinya sangat mudah via Composer dan cocok untuk development atau production dengan traffic menengah.
composer require predis/predis
Phpredis adalah extension C yang harus di-compile. Performanya lebih baik dari predis karena berjalan di level yang lebih rendah. Untuk production dengan traffic tinggi, phpredis lebih direkomendasikan. Instalasi di Ubuntu seperti berikut.
sudo apt install php-redis
sudo systemctl restart php-fpm # atau apache2
Untuk tutorial ini, kita akan menggunakan predis karena lebih mudah di-setup dan perbedaan performanya tidak signifikan untuk aplikasi dengan traffic normal.
Sekarang saatnya konfigurasi Laravel untuk menggunakan Redis. Buka file .env dan ubah beberapa nilai berikut.
# Cache menggunakan Redis
CACHE_STORE=redis
# Session menggunakan Redis
SESSION_DRIVER=redis
# Queue menggunakan Redis
QUEUE_CONNECTION=redis
# Konfigurasi koneksi Redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Jika Redis kalian menggunakan password, ganti REDIS_PASSWORD=null menjadi password yang sesuai.
Konfigurasi default Laravel sudah cukup untuk kebanyakan kasus. Tapi untuk aplikasi production, saya merekomendasikan memisahkan Redis database untuk cache, session, dan queue. Redis mendukung 16 database dengan index 0-15. Dengan memisahkan, kita bisa flush cache tanpa menghapus session atau queue jobs.
Buka file config/database.php dan modifikasi bagian redis.
<?php
return [
// ... konfigurasi lainnya
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', 'asuransiku_'),
],
// Connection untuk cache (database 0)
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
// Connection untuk cache (explicit)
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
// Connection untuk session (database 2)
'session' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_SESSION_DB', '2'),
],
// Connection untuk queue (database 3)
'queue' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_QUEUE_DB', '3'),
],
],
];
Tambahkan juga di file .env untuk database numbers.
REDIS_DB=0
REDIS_CACHE_DB=1
REDIS_SESSION_DB=2
REDIS_QUEUE_DB=3
Selanjutnya, pastikan config/cache.php menggunakan connection cache yang sudah kita definisikan.
<?php
return [
'default' => env('CACHE_STORE', 'redis'),
'stores' => [
// ... stores lainnya
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
],
'prefix' => env('CACHE_PREFIX', 'asuransiku_cache_'),
];
Untuk session, buka config/session.php dan pastikan connection mengarah ke session.
<?php
return [
'driver' => env('SESSION_DRIVER', 'redis'),
'connection' => env('SESSION_CONNECTION', 'session'),
'lifetime' => env('SESSION_LIFETIME', 10080), // 7 hari dalam menit
// ... konfigurasi lainnya
];
Untuk queue, buka config/queue.php dan tambahkan connection ke redis queue.
<?php
return [
'default' => env('QUEUE_CONNECTION', 'redis'),
'connections' => [
// ... connections lainnya
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
],
];
Sekarang saatnya testing apakah konfigurasi sudah benar. Buka terminal dan jalankan Laravel tinker.
php artisan tinker
Di dalam tinker, coba beberapa command berikut.
// Test koneksi Redis
Illuminate\\Support\\Facades\\Redis::ping();
// Expected: "PONG"
// Test cache
Illuminate\\Support\\Facades\\Cache::put('test_key', 'Hello Redis!', 60);
Illuminate\\Support\\Facades\\Cache::get('test_key');
// Expected: "Hello Redis!"
// Test session store (akan terlihat saat login)
config('session.driver');
// Expected: "redis"
// Test queue connection
config('queue.default');
// Expected: "redis"
Jika semua test berhasil, Laravel sudah terhubung dengan Redis dengan benar.
Untuk melihat data yang tersimpan di Redis, kalian bisa menggunakan redis-cli langsung atau GUI tools yang lebih user-friendly. Beberapa tools yang saya rekomendasikan adalah RedisInsight dari Redis Labs yang gratis dan feature-rich, TablePlus yang juga support berbagai database lain, dan Redis Commander yang web-based dan bisa diakses via browser.
Untuk melihat data via redis-cli, berikut beberapa command yang berguna.
# Masuk ke redis-cli
redis-cli
# Pilih database (misalnya database 1 untuk cache)
SELECT 1
# Lihat semua keys
KEYS *
# Lihat value dari specific key
GET asuransiku_cache_test_key
# Lihat tipe data dari key
TYPE asuransiku_cache_test_key
# Lihat TTL (time to live) dari key
TTL asuransiku_cache_test_key
# Hapus specific key
DEL asuransiku_cache_test_key
# Hapus semua keys di database ini
FLUSHDB
# Keluar dari redis-cli
EXIT
Perhatikan bahwa Laravel menambahkan prefix ke setiap key. Dalam konfigurasi kita, prefix-nya adalah asuransiku_ untuk Redis dan asuransiku_cache_ untuk cache. Ini membantu menghindari konflik jika Redis digunakan oleh aplikasi lain.
Sekarang environment development sudah siap. Redis server berjalan, Laravel sudah terkonfigurasi untuk menggunakan Redis sebagai cache, session, dan queue driver, dan kita sudah memverifikasi koneksi berhasil.
Di bagian selanjutnya, kita akan membuat struktur dasar project website asuransi jiwa AsuransiKu dengan database schema untuk produk, rate premi, nasabah, polis, dan klaim. Struktur ini akan menjadi fondasi untuk implementasi Redis di bagian-bagian berikutnya.
Bagian 3: Setup Project Website Asuransi Jiwa
Sebelum mengimplementasikan Redis, kita perlu membangun struktur dasar project Laravel untuk website AsuransiKu dengan database schema yang mencakup produk asuransi, tabel rate premi, data nasabah, polis, dan klaim. Struktur ini akan menjadi fondasi untuk memahami di mana Redis akan diterapkan dan memberikan konteks bisnis yang jelas untuk setiap optimasi yang akan kita lakukan.
Mari kita mulai dengan membuat project Laravel baru. Buka terminal dan jalankan command berikut.
composer create-project laravel/laravel asuransiku
cd asuransiku
Setelah project dibuat, konfigurasi dasar di file .env untuk menyesuaikan dengan kebutuhan aplikasi Indonesia.
APP_NAME=AsuransiKu
APP_ENV=local
APP_URL=http://localhost:8000
APP_TIMEZONE=Asia/Jakarta
APP_LOCALE=id
APP_FAKER_LOCALE=id_ID
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=asuransiku
DB_USERNAME=root
DB_PASSWORD=
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Buat database MySQL untuk project ini.
mysql -u root -p -e "CREATE DATABASE asuransiku CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
Sekarang kita buat migration untuk tabel-tabel utama. Mulai dengan tabel produk asuransi.
php artisan make:migration create_insurance_products_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('insurance_products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->enum('type', ['term_life', 'whole_life', 'unit_link']);
$table->text('description');
$table->text('short_description')->nullable();
$table->decimal('min_coverage', 15, 2);
$table->decimal('max_coverage', 15, 2);
$table->unsignedTinyInteger('min_age')->default(18);
$table->unsignedTinyInteger('max_age')->default(65);
$table->json('features')->nullable();
$table->json('benefits')->nullable();
$table->json('requirements')->nullable();
$table->string('brochure_url')->nullable();
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('insurance_products');
}
};
Buat migration untuk tabel rate premi yang akan menjadi lookup table untuk kalkulator.
php artisan make:migration create_premium_rates_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('premium_rates', function (Blueprint $table) {
$table->id();
$table->foreignId('insurance_product_id')->constrained()->cascadeOnDelete();
$table->unsignedTinyInteger('age_from');
$table->unsignedTinyInteger('age_to');
$table->enum('gender', ['male', 'female']);
$table->decimal('coverage_amount', 15, 2);
$table->decimal('annual_premium', 15, 2);
$table->decimal('monthly_premium', 15, 2);
$table->unsignedTinyInteger('payment_term_years')->default(10);
$table->timestamps();
$table->index(['insurance_product_id', 'gender', 'age_from', 'age_to']);
$table->index(['insurance_product_id', 'coverage_amount']);
});
}
public function down(): void
{
Schema::dropIfExists('premium_rates');
}
};
Buat migration untuk tabel customers yang menyimpan data nasabah.
php artisan make:migration create_customers_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('name');
$table->string('email')->unique();
$table->string('phone', 20);
$table->date('date_of_birth');
$table->enum('gender', ['male', 'female']);
$table->string('id_number', 20)->unique(); // NIK KTP
$table->text('address');
$table->string('city');
$table->string('province');
$table->string('postal_code', 10);
$table->string('occupation')->nullable();
$table->string('beneficiary_name')->nullable();
$table->string('beneficiary_relation')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('customers');
}
};
Buat migration untuk tabel policies yang menyimpan data polis.
php artisan make:migration create_policies_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('policies', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->foreignId('insurance_product_id')->constrained()->cascadeOnDelete();
$table->string('policy_number')->unique();
$table->decimal('coverage_amount', 15, 2);
$table->decimal('annual_premium', 15, 2);
$table->decimal('monthly_premium', 15, 2);
$table->enum('payment_frequency', ['monthly', 'quarterly', 'semi_annual', 'annual']);
$table->date('start_date');
$table->date('end_date');
$table->date('next_payment_date')->nullable();
$table->enum('status', ['pending', 'under_review', 'active', 'lapsed', 'claimed', 'cancelled']);
$table->text('notes')->nullable();
$table->timestamp('approved_at')->nullable();
$table->timestamps();
$table->index(['customer_id', 'status']);
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('policies');
}
};
Buat migration untuk tabel claims yang menyimpan pengajuan klaim.
php artisan make:migration create_claims_table
<?php
use Illuminate\\Database\\Migrations\\Migration;
use Illuminate\\Database\\Schema\\Blueprint;
use Illuminate\\Support\\Facades\\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('claims', function (Blueprint $table) {
$table->id();
$table->foreignId('policy_id')->constrained()->cascadeOnDelete();
$table->string('claim_number')->unique();
$table->enum('claim_type', ['death', 'critical_illness', 'disability', 'hospital', 'maturity']);
$table->decimal('claim_amount', 15, 2);
$table->text('description');
$table->json('documents')->nullable();
$table->enum('status', ['submitted', 'reviewing', 'approved', 'rejected', 'paid']);
$table->text('rejection_reason')->nullable();
$table->timestamp('submitted_at');
$table->timestamp('reviewed_at')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index(['policy_id', 'status']);
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('claims');
}
};
Sekarang buat model untuk setiap tabel dengan relationships yang tepat.
php artisan make:model InsuranceProduct
php artisan make:model PremiumRate
php artisan make:model Customer
php artisan make:model Policy
php artisan make:model Claim
<?php
// app/Models/InsuranceProduct.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class InsuranceProduct extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'type',
'description',
'short_description',
'min_coverage',
'max_coverage',
'min_age',
'max_age',
'features',
'benefits',
'requirements',
'brochure_url',
'is_active',
'sort_order',
];
protected $casts = [
'features' => 'array',
'benefits' => 'array',
'requirements' => 'array',
'min_coverage' => 'decimal:2',
'max_coverage' => 'decimal:2',
'is_active' => 'boolean',
];
public function premiumRates(): HasMany
{
return $this->hasMany(PremiumRate::class);
}
public function policies(): HasMany
{
return $this->hasMany(Policy::class);
}
public function getTypeNameAttribute(): string
{
return match($this->type) {
'term_life' => 'Term Life',
'whole_life' => 'Whole Life',
'unit_link' => 'Unit Link',
default => $this->type,
};
}
}
<?php
// app/Models/PremiumRate.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class PremiumRate extends Model
{
use HasFactory;
protected $fillable = [
'insurance_product_id',
'age_from',
'age_to',
'gender',
'coverage_amount',
'annual_premium',
'monthly_premium',
'payment_term_years',
];
protected $casts = [
'coverage_amount' => 'decimal:2',
'annual_premium' => 'decimal:2',
'monthly_premium' => 'decimal:2',
];
public function insuranceProduct(): BelongsTo
{
return $this->belongsTo(InsuranceProduct::class);
}
}
<?php
// app/Models/Customer.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Customer extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'name',
'email',
'phone',
'date_of_birth',
'gender',
'id_number',
'address',
'city',
'province',
'occupation',
'postal_code',
'beneficiary_name',
'beneficiary_relation',
];
protected $casts = [
'date_of_birth' => 'date',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function policies(): HasMany
{
return $this->hasMany(Policy::class);
}
public function getAgeAttribute(): int
{
return $this->date_of_birth->age;
}
}
<?php
// app/Models/Policy.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Policy extends Model
{
use HasFactory;
protected $fillable = [
'customer_id',
'insurance_product_id',
'policy_number',
'coverage_amount',
'annual_premium',
'monthly_premium',
'payment_frequency',
'start_date',
'end_date',
'next_payment_date',
'status',
'notes',
'approved_at',
];
protected $casts = [
'coverage_amount' => 'decimal:2',
'annual_premium' => 'decimal:2',
'monthly_premium' => 'decimal:2',
'start_date' => 'date',
'end_date' => 'date',
'next_payment_date' => 'date',
'approved_at' => 'datetime',
];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function insuranceProduct(): BelongsTo
{
return $this->belongsTo(InsuranceProduct::class);
}
public function claims(): HasMany
{
return $this->hasMany(Claim::class);
}
public function getStatusNameAttribute(): string
{
return match($this->status) {
'pending' => 'Menunggu',
'under_review' => 'Sedang Ditinjau',
'active' => 'Aktif',
'lapsed' => 'Tidak Aktif',
'claimed' => 'Diklaim',
'cancelled' => 'Dibatalkan',
default => $this->status,
};
}
}
<?php
// app/Models/Claim.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Claim extends Model
{
use HasFactory;
protected $fillable = [
'policy_id',
'claim_number',
'claim_type',
'claim_amount',
'description',
'documents',
'status',
'rejection_reason',
'submitted_at',
'reviewed_at',
'paid_at',
];
protected $casts = [
'claim_amount' => 'decimal:2',
'documents' => 'array',
'submitted_at' => 'datetime',
'reviewed_at' => 'datetime',
'paid_at' => 'datetime',
];
public function policy(): BelongsTo
{
return $this->belongsTo(Policy::class);
}
public function getClaimTypeNameAttribute(): string
{
return match($this->claim_type) {
'death' => 'Klaim Meninggal',
'critical_illness' => 'Penyakit Kritis',
'disability' => 'Cacat Tetap',
'hospital' => 'Rawat Inap',
'maturity' => 'Jatuh Tempo',
default => $this->claim_type,
};
}
}
Sekarang buat Factory dan Seeder untuk mengisi data awal. Mulai dengan factory untuk InsuranceProduct.
php artisan make:factory InsuranceProductFactory
php artisan make:factory PremiumRateFactory
php artisan make:factory CustomerFactory
php artisan make:factory PolicyFactory
<?php
// database/factories/InsuranceProductFactory.php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
use Illuminate\\Support\\Str;
class InsuranceProductFactory extends Factory
{
public function definition(): array
{
$name = fake()->randomElement([
'Term Life Protection',
'Whole Life Legacy',
'Unit Link Maxima',
'Family Shield',
'Executive Guard',
]);
return [
'name' => $name,
'slug' => Str::slug($name),
'type' => fake()->randomElement(['term_life', 'whole_life', 'unit_link']),
'description' => fake()->paragraphs(3, true),
'short_description' => fake()->sentence(15),
'min_coverage' => 100000000,
'max_coverage' => 5000000000,
'min_age' => 18,
'max_age' => 65,
'features' => [
'Perlindungan jiwa hingga Rp 5 Miliar',
'Premi terjangkau mulai Rp 500ribu/bulan',
'Proses klaim mudah dan cepat',
'Santunan meninggal dunia',
],
'benefits' => [
'Uang pertanggungan 100% jika meninggal dunia',
'Pembebasan premi jika cacat tetap total',
'Bonus loyalitas setiap 5 tahun',
],
'requirements' => [
'KTP',
'Kartu Keluarga',
'Hasil Medical Check Up (untuk coverage > 1M)',
],
'is_active' => true,
'sort_order' => fake()->numberBetween(1, 10),
];
}
}
<?php
// database/factories/CustomerFactory.php
namespace Database\\Factories;
use Illuminate\\Database\\Eloquent\\Factories\\Factory;
class CustomerFactory extends Factory
{
public function definition(): array
{
$gender = fake()->randomElement(['male', 'female']);
return [
'name' => fake('id_ID')->name($gender),
'email' => fake()->unique()->safeEmail(),
'phone' => fake('id_ID')->phoneNumber(),
'date_of_birth' => fake()->dateTimeBetween('-60 years', '-20 years'),
'gender' => $gender,
'id_number' => fake()->numerify('################'),
'address' => fake('id_ID')->streetAddress(),
'city' => fake('id_ID')->city(),
'province' => fake()->randomElement(['DKI Jakarta', 'Jawa Barat', 'Jawa Tengah', 'Jawa Timur', 'Banten']),
'postal_code' => fake()->numerify('#####'),
'occupation' => fake()->randomElement(['Karyawan Swasta', 'PNS', 'Wiraswasta', 'Dokter', 'Pengacara', 'Guru']),
'beneficiary_name' => fake('id_ID')->name(),
'beneficiary_relation' => fake()->randomElement(['Suami', 'Istri', 'Anak', 'Orang Tua']),
];
}
}
Buat seeder utama untuk mengisi semua data.
<?php
// database/seeders/DatabaseSeeder.php
namespace Database\\Seeders;
use App\\Models\\Claim;
use App\\Models\\Customer;
use App\\Models\\InsuranceProduct;
use App\\Models\\Policy;
use App\\Models\\PremiumRate;
use App\\Models\\User;
use Illuminate\\Database\\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Create admin user
User::factory()->create([
'name' => 'Admin AsuransiKu',
'email' => '[email protected]',
]);
// Create insurance products
$this->createInsuranceProducts();
// Create premium rates
$this->createPremiumRates();
// Create customers and policies
$this->createCustomersAndPolicies();
$this->command->info('Database seeded successfully!');
}
private function createInsuranceProducts(): void
{
$products = [
[
'name' => 'Term Life Protection',
'slug' => 'term-life-protection',
'type' => 'term_life',
'description' => 'Asuransi jiwa berjangka dengan premi terjangkau. Memberikan perlindungan maksimal dengan biaya minimal untuk keluarga tercinta.',
'short_description' => 'Perlindungan jiwa berjangka dengan premi ringan',
'min_coverage' => 100000000,
'max_coverage' => 2000000000,
'features' => ['Premi ringan', 'Perlindungan maksimal', 'Tanpa medical check up hingga 500 juta'],
'sort_order' => 1,
],
[
'name' => 'Whole Life Legacy',
'slug' => 'whole-life-legacy',
'type' => 'whole_life',
'description' => 'Asuransi jiwa seumur hidup dengan nilai tunai yang terus bertumbuh. Warisan finansial untuk generasi berikutnya.',
'short_description' => 'Perlindungan seumur hidup dengan nilai tunai',
'min_coverage' => 250000000,
'max_coverage' => 5000000000,
'features' => ['Perlindungan seumur hidup', 'Nilai tunai bertumbuh', 'Bisa dijadikan jaminan pinjaman'],
'sort_order' => 2,
],
[
'name' => 'Unit Link Maxima',
'slug' => 'unit-link-maxima',
'type' => 'unit_link',
'description' => 'Kombinasi proteksi jiwa dan investasi dalam satu produk. Dapatkan perlindungan sambil mengembangkan aset.',
'short_description' => 'Proteksi plus investasi dalam satu produk',
'min_coverage' => 500000000,
'max_coverage' => 10000000000,
'features' => ['Proteksi jiwa', 'Investasi fleksibel', 'Pilihan fund beragam', 'Top up kapan saja'],
'sort_order' => 3,
],
];
foreach ($products as $product) {
InsuranceProduct::create(array_merge($product, [
'min_age' => 18,
'max_age' => 65,
'is_active' => true,
'benefits' => [
'Santunan meninggal dunia 100%',
'Santunan cacat tetap total',
'Pembebasan premi',
],
'requirements' => ['KTP', 'Kartu Keluarga', 'Slip Gaji'],
]));
}
$this->command->info('Created 3 insurance products');
}
private function createPremiumRates(): void
{
$products = InsuranceProduct::all();
$coverages = [100000000, 250000000, 500000000, 1000000000, 2000000000];
$ageRanges = [
['from' => 18, 'to' => 25],
['from' => 26, 'to' => 35],
['from' => 36, 'to' => 45],
['from' => 46, 'to' => 55],
['from' => 56, 'to' => 65],
];
$rateCount = 0;
foreach ($products as $product) {
foreach ($ageRanges as $ageRange) {
foreach (['male', 'female'] as $gender) {
foreach ($coverages as $coverage) {
if ($coverage < $product->min_coverage || $coverage > $product->max_coverage) {
continue;
}
$baseRate = $this->calculateBaseRate($product->type, $ageRange['from'], $gender, $coverage);
PremiumRate::create([
'insurance_product_id' => $product->id,
'age_from' => $ageRange['from'],
'age_to' => $ageRange['to'],
'gender' => $gender,
'coverage_amount' => $coverage,
'annual_premium' => $baseRate,
'monthly_premium' => round($baseRate / 12, -3),
'payment_term_years' => 10,
]);
$rateCount++;
}
}
}
}
$this->command->info("Created {$rateCount} premium rates");
}
private function calculateBaseRate(string $type, int $age, string $gender, float $coverage): float
{
$baseMultiplier = match($type) {
'term_life' => 0.003,
'whole_life' => 0.015,
'unit_link' => 0.025,
default => 0.01,
};
$ageMultiplier = 1 + (($age - 18) * 0.02);
$genderMultiplier = $gender === 'male' ? 1.1 : 1.0;
$premium = $coverage * $baseMultiplier * $ageMultiplier * $genderMultiplier;
return round($premium, -4);
}
private function createCustomersAndPolicies(): void
{
$products = InsuranceProduct::all();
$customers = Customer::factory(50)->create();
foreach ($customers as $customer) {
$numPolicies = fake()->numberBetween(1, 2);
for ($i = 0; $i < $numPolicies; $i++) {
$product = $products->random();
$coverage = fake()->randomElement([100000000, 250000000, 500000000, 1000000000]);
$rate = PremiumRate::where('insurance_product_id', $product->id)
->where('age_from', '<=', $customer->age)
->where('age_to', '>=', $customer->age)
->where('gender', $customer->gender)
->where('coverage_amount', '<=', $coverage)
->orderBy('coverage_amount', 'desc')
->first();
if (!$rate) continue;
$startDate = fake()->dateTimeBetween('-2 years', 'now');
Policy::create([
'customer_id' => $customer->id,
'insurance_product_id' => $product->id,
'policy_number' => 'POL-' . strtoupper(uniqid()),
'coverage_amount' => $rate->coverage_amount,
'annual_premium' => $rate->annual_premium,
'monthly_premium' => $rate->monthly_premium,
'payment_frequency' => fake()->randomElement(['monthly', 'annual']),
'start_date' => $startDate,
'end_date' => date('Y-m-d', strtotime('+10 years', strtotime($startDate->format('Y-m-d')))),
'status' => fake()->randomElement(['active', 'active', 'active', 'pending', 'under_review']),
'approved_at' => fake()->boolean(80) ? now() : null,
]);
}
}
$this->command->info('Created 50 customers with policies');
}
}
Jalankan migration dan seeder.
php artisan migrate:fresh --seed
Sekarang buat controller dasar untuk menampilkan produk. Ini akan kita optimalkan dengan Redis di bagian selanjutnya.
php artisan make:controller ProductController
php artisan make:controller PremiumCalculatorController
<?php
// app/Http/Controllers/ProductController.php
namespace App\\Http\\Controllers;
use App\\Models\\InsuranceProduct;
use Illuminate\\Http\\JsonResponse;
class ProductController extends Controller
{
public function index(): JsonResponse
{
$products = InsuranceProduct::where('is_active', true)
->orderBy('sort_order')
->get();
return response()->json([
'success' => true,
'data' => $products,
]);
}
public function show(string $slug): JsonResponse
{
$product = InsuranceProduct::where('slug', $slug)
->where('is_active', true)
->with('premiumRates')
->firstOrFail();
return response()->json([
'success' => true,
'data' => $product,
]);
}
}
<?php
// app/Http/Controllers/PremiumCalculatorController.php
namespace App\\Http\\Controllers;
use App\\Models\\PremiumRate;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
class PremiumCalculatorController extends Controller
{
public function calculate(Request $request): JsonResponse
{
$validated = $request->validate([
'product_id' => 'required|exists:insurance_products,id',
'date_of_birth' => 'required|date',
'gender' => 'required|in:male,female',
'coverage_amount' => 'required|numeric|min:100000000',
]);
$age = now()->diffInYears($validated['date_of_birth']);
$rate = PremiumRate::where('insurance_product_id', $validated['product_id'])
->where('age_from', '<=', $age)
->where('age_to', '>=', $age)
->where('gender', $validated['gender'])
->where('coverage_amount', '>=', $validated['coverage_amount'])
->orderBy('coverage_amount', 'asc')
->first();
if (!$rate) {
return response()->json([
'success' => false,
'message' => 'Rate tidak ditemukan untuk kriteria yang dipilih',
], 404);
}
return response()->json([
'success' => true,
'data' => [
'coverage_amount' => $rate->coverage_amount,
'annual_premium' => $rate->annual_premium,
'monthly_premium' => $rate->monthly_premium,
'age' => $age,
],
]);
}
}
Tambahkan routes di file routes/api.php.
<?php
use App\\Http\\Controllers\\ProductController;
use App\\Http\\Controllers\\PremiumCalculatorController;
use Illuminate\\Support\\Facades\\Route;
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{slug}', [ProductController::class, 'show']);
Route::post('/calculate-premium', [PremiumCalculatorController::class, 'calculate']);
Jalankan server dan test endpoint.
php artisan serve
# Test list products
curl <http://localhost:8000/api/products>
# Test calculate premium
curl -X POST <http://localhost:8000/api/calculate-premium> \\
-H "Content-Type: application/json" \\
-d '{"product_id": 1, "date_of_birth": "1990-05-15", "gender": "male", "coverage_amount": 500000000}'
Struktur dasar project AsuransiKu sudah siap. Kita punya 3 produk asuransi, ratusan rate premi, 50 nasabah dengan polis, dan endpoint API untuk menampilkan produk serta kalkulasi premi. Di bagian selanjutnya, kita akan mulai mengoptimalkan dengan Redis, dimulai dari caching data produk yang sering diakses.
Bagian 4: Implementasi Caching untuk Data Produk Asuransi
Implementasi Redis caching untuk data produk asuransi mengubah response time dari 50-100ms menjadi kurang dari 5ms. Data produk yang diakses ribuan kali per hari tapi hanya diupdate beberapa kali sebulan adalah kandidat sempurna untuk caching. Dengan pattern Cache-Aside dan cache invalidation yang tepat melalui Observer, kita memastikan data selalu fresh tanpa mengorbankan performa.
Di bagian sebelumnya, kita sudah membuat endpoint untuk menampilkan produk asuransi. Setiap kali endpoint dipanggil, Laravel query ke database untuk mengambil data. Sekarang bayangkan website AsuransiKu sudah launch dan ada 1000 calon nasabah mengunjungi halaman produk dalam satu jam. Itu artinya 1000 query identik ke database untuk data yang sama persis.
Mari kita ukur dulu berapa lama query saat ini membutuhkan waktu. Buka Laravel Tinker dan jalankan benchmark sederhana.
php artisan tinker
$start = microtime(true);
for ($i = 0; $i < 100; $i++) {
App\\Models\\InsuranceProduct::where('is_active', true)->orderBy('sort_order')->get();
}
$end = microtime(true);
echo "100 queries took: " . round(($end - $start) * 1000, 2) . "ms\\n";
echo "Average per query: " . round(($end - $start) * 10, 2) . "ms\\n";
Di laptop saya, hasilnya sekitar 5-10ms per query. Angka ini akan meningkat seiring bertambahnya data dan load server. Dengan Redis, kita bisa menurunkannya menjadi di bawah 1ms.
Strategi yang akan kita gunakan adalah Cache-Aside pattern. Logikanya sederhana. Ketika ada request, cek dulu apakah data ada di cache. Jika ada, langsung return dari cache. Jika tidak ada, query database, simpan hasilnya ke cache, lalu return. Dengan cara ini, hanya request pertama yang query database, sisanya langsung dari Redis.
Buat service class untuk menangani logic caching produk. Dengan memisahkan logic ke service, controller tetap clean dan caching bisa di-reuse di berbagai tempat.
php artisan make:class Services/ProductService
<?php
// app/Services/ProductService.php
namespace App\\Services;
use App\\Models\\InsuranceProduct;
use Illuminate\\Database\\Eloquent\\Collection;
use Illuminate\\Support\\Facades\\Cache;
class ProductService
{
private const CACHE_TTL = 86400; // 24 jam dalam detik
private const CACHE_PREFIX = 'products:';
public function getAllProducts(): Collection
{
return Cache::remember(
self::CACHE_PREFIX . 'all',
self::CACHE_TTL,
function () {
return InsuranceProduct::where('is_active', true)
->orderBy('sort_order')
->get();
}
);
}
public function getProductBySlug(string $slug): ?InsuranceProduct
{
return Cache::remember(
self::CACHE_PREFIX . 'slug:' . $slug,
self::CACHE_TTL,
function () use ($slug) {
return InsuranceProduct::where('slug', $slug)
->where('is_active', true)
->first();
}
);
}
public function getProductById(int $id): ?InsuranceProduct
{
return Cache::remember(
self::CACHE_PREFIX . 'id:' . $id,
self::CACHE_TTL,
function () use ($id) {
return InsuranceProduct::find($id);
}
);
}
public function getProductWithRates(string $slug): ?InsuranceProduct
{
return Cache::remember(
self::CACHE_PREFIX . 'with_rates:' . $slug,
self::CACHE_TTL,
function () use ($slug) {
return InsuranceProduct::where('slug', $slug)
->where('is_active', true)
->with('premiumRates')
->first();
}
);
}
public function clearAllCache(): void
{
$keys = [
self::CACHE_PREFIX . 'all',
];
// Hapus cache untuk semua produk individual
$products = InsuranceProduct::all(['id', 'slug']);
foreach ($products as $product) {
$keys[] = self::CACHE_PREFIX . 'slug:' . $product->slug;
$keys[] = self::CACHE_PREFIX . 'id:' . $product->id;
$keys[] = self::CACHE_PREFIX . 'with_rates:' . $product->slug;
}
foreach ($keys as $key) {
Cache::forget($key);
}
}
public function clearProductCache(InsuranceProduct $product): void
{
Cache::forget(self::CACHE_PREFIX . 'all');
Cache::forget(self::CACHE_PREFIX . 'slug:' . $product->slug);
Cache::forget(self::CACHE_PREFIX . 'id:' . $product->id);
Cache::forget(self::CACHE_PREFIX . 'with_rates:' . $product->slug);
}
}
Method Cache::remember() adalah helper yang sangat berguna. Parameter pertama adalah cache key, parameter kedua adalah TTL (time to live) dalam detik, dan parameter ketiga adalah closure yang dijalankan jika cache miss. Laravel secara otomatis menyimpan hasil closure ke cache.
Sekarang update ProductController untuk menggunakan ProductService.
<?php
// app/Http/Controllers/ProductController.php
namespace App\\Http\\Controllers;
use App\\Services\\ProductService;
use Illuminate\\Http\\JsonResponse;
class ProductController extends Controller
{
public function __construct(
private ProductService $productService
) {}
public function index(): JsonResponse
{
$products = $this->productService->getAllProducts();
return response()->json([
'success' => true,
'data' => $products,
]);
}
public function show(string $slug): JsonResponse
{
$product = $this->productService->getProductWithRates($slug);
if (!$product) {
return response()->json([
'success' => false,
'message' => 'Produk tidak ditemukan',
], 404);
}
return response()->json([
'success' => true,
'data' => $product,
]);
}
}
Controller sekarang sangat clean. Semua logic caching tersembunyi di dalam service. Mari kita test dan bandingkan performanya.
php artisan tinker
// Pastikan cache kosong dulu
Illuminate\\Support\\Facades\\Cache::flush();
// Test pertama - cache miss, harus query database
$start = microtime(true);
app(App\\Services\\ProductService::class)->getAllProducts();
$first = (microtime(true) - $start) * 1000;
echo "First call (cache miss): " . round($first, 2) . "ms\\n";
// Test kedua - cache hit, ambil dari Redis
$start = microtime(true);
app(App\\Services\\ProductService::class)->getAllProducts();
$second = (microtime(true) - $start) * 1000;
echo "Second call (cache hit): " . round($second, 2) . "ms\\n";
// Improvement
echo "Improvement: " . round((1 - $second/$first) * 100, 1) . "%\\n";
Di laptop saya, first call membutuhkan sekitar 8ms sedangkan second call hanya 0.5ms. Itu improvement lebih dari 90%!
Sekarang masalah penting yang harus ditangani adalah cache invalidation. Ketika admin mengupdate data produk, cache harus di-clear agar user tidak melihat data stale. Kita akan menggunakan Observer untuk otomatis clear cache setiap kali ada perubahan data.
php artisan make:observer InsuranceProductObserver --model=InsuranceProduct
<?php
// app/Observers/InsuranceProductObserver.php
namespace App\\Observers;
use App\\Models\\InsuranceProduct;
use App\\Services\\ProductService;
class InsuranceProductObserver
{
public function __construct(
private ProductService $productService
) {}
public function created(InsuranceProduct $product): void
{
$this->productService->clearAllCache();
}
public function updated(InsuranceProduct $product): void
{
$this->productService->clearProductCache($product);
// Clear 'all' cache juga karena list berubah
\\Illuminate\\Support\\Facades\\Cache::forget('products:all');
}
public function deleted(InsuranceProduct $product): void
{
$this->productService->clearAllCache();
}
}
Register observer di AppServiceProvider.
<?php
// app/Providers/AppServiceProvider.php
namespace App\\Providers;
use App\\Models\\InsuranceProduct;
use App\\Observers\\InsuranceProductObserver;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
InsuranceProduct::observe(InsuranceProductObserver::class);
}
}
Sekarang setiap kali produk dibuat, diupdate, atau dihapus, cache otomatis ter-invalidate. Mari kita test.
php artisan tinker
// Ambil produk dan pastikan ter-cache
$product = app(App\\Services\\ProductService::class)->getProductBySlug('term-life-protection');
echo "Product cached\\n";
// Cek cache exists
echo "Cache exists: " . (Cache::has('products:slug:term-life-protection') ? 'Yes' : 'No') . "\\n";
// Update produk
$model = App\\Models\\InsuranceProduct::where('slug', 'term-life-protection')->first();
$model->update(['short_description' => 'Updated description ' . now()]);
echo "Product updated\\n";
// Cek cache sudah terhapus
echo "Cache exists after update: " . (Cache::has('products:slug:term-life-protection') ? 'Yes' : 'No') . "\\n";
Cache berhasil ter-invalidate otomatis setelah update.
Untuk data yang critical dan sering diakses, kita bisa melakukan cache warming saat deploy atau melalui scheduled command. Ini memastikan user pertama tidak perlu menunggu cold cache.
php artisan make:command WarmProductCache
<?php
// app/Console/Commands/WarmProductCache.php
namespace App\\Console\\Commands;
use App\\Services\\ProductService;
use App\\Models\\InsuranceProduct;
use Illuminate\\Console\\Command;
class WarmProductCache extends Command
{
protected $signature = 'cache:warm-products';
protected $description = 'Warm up product cache for better performance';
public function __construct(
private ProductService $productService
) {
parent::__construct();
}
public function handle(): int
{
$this->info('Warming product cache...');
// Clear existing cache
$this->productService->clearAllCache();
$this->info('✓ Cleared existing cache');
// Warm all products list
$this->productService->getAllProducts();
$this->info('✓ Cached all products list');
// Warm individual product pages
$products = InsuranceProduct::where('is_active', true)->get();
$bar = $this->output->createProgressBar($products->count());
foreach ($products as $product) {
$this->productService->getProductBySlug($product->slug);
$this->productService->getProductWithRates($product->slug);
$bar->advance();
}
$bar->finish();
$this->newLine();
$this->info('✓ Cached ' . $products->count() . ' individual products');
$this->newLine();
$this->info('Product cache warmed successfully!');
return Command::SUCCESS;
}
}
Jalankan command untuk warm cache.
php artisan cache:warm-products
Tambahkan command ini ke deployment script atau schedule untuk berjalan setelah setiap deploy.
<?php
// routes/console.php
use Illuminate\\Support\\Facades\\Schedule;
Schedule::command('cache:warm-products')->dailyAt('03:00');
Untuk melihat apa yang tersimpan di Redis, gunakan redis-cli.
redis-cli
# Pilih database cache (sesuai konfigurasi, database 1)
SELECT 1
# Lihat semua keys dengan prefix products
KEYS *products*
# Lihat isi salah satu cache
GET asuransiku_cache_products:all
Data yang tersimpan adalah serialized PHP object. Laravel otomatis serialize saat menyimpan dan unserialize saat mengambil.
Sekarang endpoint produk kita sudah blazingly fast. Request pertama mungkin membutuhkan 5-10ms untuk query database, tapi semua request berikutnya hanya butuh kurang dari 1ms karena langsung diambil dari Redis. Dengan 1000 request per jam, kita menghemat 999 query database.
Di bagian selanjutnya, kita akan menerapkan caching untuk tabel rate premi yang lebih kompleks. Rate table memiliki ratusan baris dan membutuhkan struktur data Redis yang berbeda untuk lookup yang efisien.
Bagian 5: Caching Tabel Rate Premi untuk Kalkulator Cepat
Kalkulator premi adalah fitur yang paling sering digunakan calon nasabah untuk mencoba berbagai skenario coverage. Dengan Redis Hash, kita bisa menyimpan tabel rate premi dalam struktur yang memungkinkan O(1) lookup, mengubah kalkulasi yang tadinya butuh query database menjadi operasi memory yang instan. Benchmark menunjukkan improvement dari 15-20ms per kalkulasi menjadi kurang dari 1ms.
Tabel premium_rates di database kita berisi ratusan baris untuk semua kombinasi produk, rentang usia, gender, dan coverage amount. Setiap kali nasabah menggunakan kalkulator, sistem harus mencari rate yang sesuai dengan kriteria yang dipilih. Jika nasabah mencoba 10 skenario berbeda, itu 10 query ke database. Kalikan dengan ratusan nasabah per jam, database server akan sibuk menangani query yang sebenarnya bisa di-cache.
Berbeda dengan caching produk yang menggunakan string biasa, untuk rate table kita akan menggunakan Redis Hash. Hash adalah struktur data key-value di dalam key utama. Ini memungkinkan kita menyimpan semua rate untuk satu produk dalam satu key, dengan field yang bisa di-query langsung.
Buat service class untuk menangani premium rate dengan Redis.
php artisan make:class Services/PremiumRateService
<?php
// app/Services/PremiumRateService.php
namespace App\\Services;
use App\\Models\\InsuranceProduct;
use App\\Models\\PremiumRate;
use Illuminate\\Support\\Facades\\Redis;
use Illuminate\\Support\\Facades\\Cache;
class PremiumRateService
{
private const CACHE_PREFIX = 'premium_rates:';
private const CACHE_TTL = 86400;
public function loadRatesToCache(): int
{
$products = InsuranceProduct::all();
$totalRates = 0;
foreach ($products as $product) {
$rates = PremiumRate::where('insurance_product_id', $product->id)->get();
$hashData = [];
foreach ($rates as $rate) {
// Format field: age_from-age_to:gender:coverage
$field = sprintf(
'%d-%d:%s:%d',
$rate->age_from,
$rate->age_to,
$rate->gender,
(int) $rate->coverage_amount
);
// Value: annual_premium|monthly_premium
$value = sprintf(
'%d|%d',
(int) $rate->annual_premium,
(int) $rate->monthly_premium
);
$hashData[$field] = $value;
}
if (!empty($hashData)) {
$key = self::CACHE_PREFIX . $product->id;
// Hapus hash lama jika ada
Redis::del($key);
// Simpan semua rates dalam satu command
Redis::hmset($key, $hashData);
// Set expiration
Redis::expire($key, self::CACHE_TTL);
$totalRates += count($hashData);
}
}
return $totalRates;
}
public function calculatePremium(int $productId, int $age, string $gender, float $coverage): ?array
{
// Coba ambil dari Redis Hash dulu
$result = $this->lookupFromCache($productId, $age, $gender, $coverage);
if ($result) {
return $result;
}
// Fallback ke database jika cache miss
return $this->lookupFromDatabase($productId, $age, $gender, $coverage);
}
private function lookupFromCache(int $productId, int $age, string $gender, float $coverage): ?array
{
$key = self::CACHE_PREFIX . $productId;
// Cek apakah hash exists
if (!Redis::exists($key)) {
return null;
}
// Ambil semua fields dari hash
$allRates = Redis::hgetall($key);
if (empty($allRates)) {
return null;
}
// Cari rate yang cocok dengan kriteria
foreach ($allRates as $field => $value) {
// Parse field: age_from-age_to:gender:coverage
if (!preg_match('/^(\\d+)-(\\d+):(\\w+):(\\d+)$/', $field, $matches)) {
continue;
}
$ageFrom = (int) $matches[1];
$ageTo = (int) $matches[2];
$rateGender = $matches[3];
$rateCoverage = (int) $matches[4];
// Cek apakah kriteria cocok
if ($age >= $ageFrom &&
$age <= $ageTo &&
$gender === $rateGender &&
$coverage <= $rateCoverage) {
// Parse value: annual|monthly
[$annual, $monthly] = explode('|', $value);
return [
'coverage_amount' => $rateCoverage,
'annual_premium' => (int) $annual,
'monthly_premium' => (int) $monthly,
'source' => 'cache',
];
}
}
return null;
}
private function lookupFromDatabase(int $productId, int $age, string $gender, float $coverage): ?array
{
$rate = PremiumRate::where('insurance_product_id', $productId)
->where('age_from', '<=', $age)
->where('age_to', '>=', $age)
->where('gender', $gender)
->where('coverage_amount', '>=', $coverage)
->orderBy('coverage_amount', 'asc')
->first();
if (!$rate) {
return null;
}
return [
'coverage_amount' => (float) $rate->coverage_amount,
'annual_premium' => (float) $rate->annual_premium,
'monthly_premium' => (float) $rate->monthly_premium,
'source' => 'database',
];
}
public function getAvailableCoverages(int $productId, int $age, string $gender): array
{
$cacheKey = "coverages:{$productId}:{$age}:{$gender}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($productId, $age, $gender) {
return PremiumRate::where('insurance_product_id', $productId)
->where('age_from', '<=', $age)
->where('age_to', '>=', $age)
->where('gender', $gender)
->orderBy('coverage_amount', 'asc')
->pluck('coverage_amount')
->map(fn ($amount) => (float) $amount)
->values()
->toArray();
});
}
public function clearCache(): void
{
$products = InsuranceProduct::all(['id']);
foreach ($products as $product) {
Redis::del(self::CACHE_PREFIX . $product->id);
}
// Clear coverage cache juga
$keys = Redis::keys('*coverages:*');
if (!empty($keys)) {
Redis::del($keys);
}
}
}
Sekarang buat method yang lebih optimal untuk lookup dengan direct field access. Jika kita tahu exact key yang dicari, kita bisa menggunakan HGET langsung tanpa perlu iterate semua fields.
<?php
// Tambahkan method ini di PremiumRateService
public function calculatePremiumOptimized(int $productId, int $age, string $gender, float $coverage): ?array
{
$key = self::CACHE_PREFIX . $productId;
// Tentukan age range berdasarkan usia
$ageRange = $this->getAgeRange($age);
if (!$ageRange) {
return null;
}
// Cari coverage yang tersedia dan cocok
$availableCoverages = $this->getAvailableCoveragesFromHash($productId);
$matchedCoverage = $this->findMatchingCoverage($availableCoverages, $coverage);
if (!$matchedCoverage) {
return $this->lookupFromDatabase($productId, $age, $gender, $coverage);
}
// Direct lookup dengan exact field
$field = sprintf('%d-%d:%s:%d', $ageRange['from'], $ageRange['to'], $gender, $matchedCoverage);
$value = Redis::hget($key, $field);
if (!$value) {
return $this->lookupFromDatabase($productId, $age, $gender, $coverage);
}
[$annual, $monthly] = explode('|', $value);
return [
'coverage_amount' => $matchedCoverage,
'annual_premium' => (int) $annual,
'monthly_premium' => (int) $monthly,
'source' => 'cache_direct',
];
}
private function getAgeRange(int $age): ?array
{
$ranges = [
['from' => 18, 'to' => 25],
['from' => 26, 'to' => 35],
['from' => 36, 'to' => 45],
['from' => 46, 'to' => 55],
['from' => 56, 'to' => 65],
];
foreach ($ranges as $range) {
if ($age >= $range['from'] && $age <= $range['to']) {
return $range;
}
}
return null;
}
private function getAvailableCoveragesFromHash(int $productId): array
{
$cacheKey = "coverage_list:{$productId}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($productId) {
$key = self::CACHE_PREFIX . $productId;
$fields = Redis::hkeys($key);
$coverages = [];
foreach ($fields as $field) {
if (preg_match('/:(\\d+)$/', $field, $matches)) {
$coverages[] = (int) $matches[1];
}
}
return array_unique($coverages);
});
}
private function findMatchingCoverage(array $coverages, float $requested): ?int
{
sort($coverages);
foreach ($coverages as $coverage) {
if ($coverage >= $requested) {
return $coverage;
}
}
return null;
}
Update PremiumCalculatorController untuk menggunakan service baru.
<?php
// app/Http/Controllers/PremiumCalculatorController.php
namespace App\\Http\\Controllers;
use App\\Services\\PremiumRateService;
use App\\Services\\ProductService;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
class PremiumCalculatorController extends Controller
{
public function __construct(
private PremiumRateService $premiumRateService,
private ProductService $productService
) {}
public function calculate(Request $request): JsonResponse
{
$validated = $request->validate([
'product_id' => 'required|integer',
'date_of_birth' => 'required|date',
'gender' => 'required|in:male,female',
'coverage_amount' => 'required|numeric|min:100000000',
]);
// Validasi produk exists
$product = $this->productService->getProductById($validated['product_id']);
if (!$product) {
return response()->json([
'success' => false,
'message' => 'Produk tidak ditemukan',
], 404);
}
// Hitung usia
$age = now()->diffInYears($validated['date_of_birth']);
// Validasi usia
if ($age < $product->min_age || $age > $product->max_age) {
return response()->json([
'success' => false,
'message' => "Usia harus antara {$product->min_age} - {$product->max_age} tahun untuk produk ini",
], 422);
}
// Validasi coverage
if ($validated['coverage_amount'] < $product->min_coverage ||
$validated['coverage_amount'] > $product->max_coverage) {
return response()->json([
'success' => false,
'message' => sprintf(
'Coverage harus antara %s - %s untuk produk ini',
'Rp ' . number_format($product->min_coverage, 0, ',', '.'),
'Rp ' . number_format($product->max_coverage, 0, ',', '.')
),
], 422);
}
// Hitung premi menggunakan cache
$result = $this->premiumRateService->calculatePremiumOptimized(
$validated['product_id'],
$age,
$validated['gender'],
$validated['coverage_amount']
);
if (!$result) {
return response()->json([
'success' => false,
'message' => 'Rate tidak ditemukan untuk kriteria yang dipilih',
], 404);
}
return response()->json([
'success' => true,
'data' => [
'product' => [
'id' => $product->id,
'name' => $product->name,
'type' => $product->type_name,
],
'customer' => [
'age' => $age,
'gender' => $validated['gender'] === 'male' ? 'Pria' : 'Wanita',
],
'premium' => [
'coverage_amount' => $result['coverage_amount'],
'coverage_formatted' => 'Rp ' . number_format($result['coverage_amount'], 0, ',', '.'),
'annual_premium' => $result['annual_premium'],
'annual_formatted' => 'Rp ' . number_format($result['annual_premium'], 0, ',', '.'),
'monthly_premium' => $result['monthly_premium'],
'monthly_formatted' => 'Rp ' . number_format($result['monthly_premium'], 0, ',', '.'),
],
'meta' => [
'source' => $result['source'],
'calculated_at' => now()->toISOString(),
],
],
]);
}
public function getAvailableCoverages(Request $request): JsonResponse
{
$validated = $request->validate([
'product_id' => 'required|integer',
'date_of_birth' => 'required|date',
'gender' => 'required|in:male,female',
]);
$age = now()->diffInYears($validated['date_of_birth']);
$coverages = $this->premiumRateService->getAvailableCoverages(
$validated['product_id'],
$age,
$validated['gender']
);
return response()->json([
'success' => true,
'data' => [
'coverages' => $coverages,
'formatted' => array_map(
fn ($c) => 'Rp ' . number_format($c, 0, ',', '.'),
$coverages
),
],
]);
}
}
Tambahkan route baru.
<?php
// routes/api.php
Route::post('/calculate-premium', [PremiumCalculatorController::class, 'calculate']);
Route::post('/available-coverages', [PremiumCalculatorController::class, 'getAvailableCoverages']);
Buat command untuk warm cache rate premi.
php artisan make:command WarmPremiumRatesCache
<?php
// app/Console/Commands/WarmPremiumRatesCache.php
namespace App\\Console\\Commands;
use App\\Services\\PremiumRateService;
use Illuminate\\Console\\Command;
class WarmPremiumRatesCache extends Command
{
protected $signature = 'cache:warm-rates';
protected $description = 'Load all premium rates into Redis cache';
public function __construct(
private PremiumRateService $premiumRateService
) {
parent::__construct();
}
public function handle(): int
{
$this->info('Loading premium rates to Redis cache...');
$startTime = microtime(true);
$totalRates = $this->premiumRateService->loadRatesToCache();
$duration = round((microtime(true) - $startTime) * 1000, 2);
$this->info("✓ Loaded {$totalRates} rates to cache in {$duration}ms");
return Command::SUCCESS;
}
}
Jalankan command untuk populate cache.
php artisan cache:warm-rates
Sekarang mari kita benchmark perbedaan performa. Buat command khusus untuk benchmark.
php artisan make:command BenchmarkPremiumCalculator
<?php
// app/Console/Commands/BenchmarkPremiumCalculator.php
namespace App\\Console\\Commands;
use App\\Services\\PremiumRateService;
use App\\Models\\PremiumRate;
use Illuminate\\Console\\Command;
class BenchmarkPremiumCalculator extends Command
{
protected $signature = 'benchmark:premium {iterations=100}';
protected $description = 'Benchmark premium calculation with and without cache';
public function __construct(
private PremiumRateService $premiumRateService
) {
parent::__construct();
}
public function handle(): int
{
$iterations = (int) $this->argument('iterations');
$this->info("Running benchmark with {$iterations} iterations...\\n");
// Test data
$testCases = [
['product_id' => 1, 'age' => 30, 'gender' => 'male', 'coverage' => 500000000],
['product_id' => 2, 'age' => 35, 'gender' => 'female', 'coverage' => 250000000],
['product_id' => 3, 'age' => 40, 'gender' => 'male', 'coverage' => 1000000000],
];
// Benchmark database query
$this->info('Testing database queries...');
$dbTimes = [];
foreach (range(1, $iterations) as $i) {
$case = $testCases[$i % count($testCases)];
$start = microtime(true);
PremiumRate::where('insurance_product_id', $case['product_id'])
->where('age_from', '<=', $case['age'])
->where('age_to', '>=', $case['age'])
->where('gender', $case['gender'])
->where('coverage_amount', '>=', $case['coverage'])
->orderBy('coverage_amount', 'asc')
->first();
$dbTimes[] = (microtime(true) - $start) * 1000;
}
// Benchmark Redis cache
$this->info('Testing Redis cache...');
$cacheTimes = [];
// Pastikan cache sudah warm
$this->premiumRateService->loadRatesToCache();
foreach (range(1, $iterations) as $i) {
$case = $testCases[$i % count($testCases)];
$start = microtime(true);
$this->premiumRateService->calculatePremiumOptimized(
$case['product_id'],
$case['age'],
$case['gender'],
$case['coverage']
);
$cacheTimes[] = (microtime(true) - $start) * 1000;
}
// Calculate statistics
$dbAvg = array_sum($dbTimes) / count($dbTimes);
$cacheAvg = array_sum($cacheTimes) / count($cacheTimes);
$improvement = (1 - $cacheAvg / $dbAvg) * 100;
$this->newLine();
$this->table(
['Metric', 'Database', 'Redis Cache', 'Improvement'],
[
['Average', round($dbAvg, 3) . 'ms', round($cacheAvg, 3) . 'ms', round($improvement, 1) . '%'],
['Min', round(min($dbTimes), 3) . 'ms', round(min($cacheTimes), 3) . 'ms', '-'],
['Max', round(max($dbTimes), 3) . 'ms', round(max($cacheTimes), 3) . 'ms', '-'],
['Total', round(array_sum($dbTimes), 2) . 'ms', round(array_sum($cacheTimes), 2) . 'ms', '-'],
]
);
$this->newLine();
$this->info("Redis cache is " . round($dbAvg / $cacheAvg, 1) . "x faster than database!");
return Command::SUCCESS;
}
}
Jalankan benchmark.
php artisan benchmark:premium 100
Di environment saya, hasilnya menunjukkan database query membutuhkan rata-rata 3-5ms sedangkan Redis cache hanya 0.2-0.5ms. Itu improvement sekitar 90% atau 10x lebih cepat.
Untuk melihat data di Redis Hash, gunakan redis-cli.
redis-cli
SELECT 1
# Lihat semua keys rate
KEYS *premium_rates*
# Lihat semua fields dalam hash untuk product 1
HGETALL asuransiku_premium_rates:1
# Lihat field spesifik
HGET asuransiku_premium_rates:1 "26-35:male:500000000"
Dengan implementasi ini, kalkulator premi menjadi sangat responsif. Calon nasabah bisa mencoba berbagai skenario coverage tanpa delay yang terasa. Setiap kalkulasi yang tadinya membutuhkan query database sekarang hanya butuh lookup ke Redis yang super cepat.
Di bagian selanjutnya, kita akan mengimplementasikan Redis untuk session management agar aplikasi siap untuk horizontal scaling.
Bagian 6: Session Management dengan Redis
Session management dengan Redis memungkinkan aplikasi AsuransiKu untuk horizontal scaling dengan multiple server tanpa kehilangan session user. Berbeda dengan file-based session yang terikat pada satu server, Redis session bisa diakses dari server manapun. Selain itu, Redis session lebih cepat karena beroperasi di memory dan mendukung fitur advanced seperti session enrichment dan logout from all devices.
Ketika nasabah login ke dashboard AsuransiKu untuk melihat polis atau mengajukan klaim, Laravel membuat session untuk menyimpan informasi authentication. Secara default, session disimpan sebagai file di folder storage/framework/sessions. Ini bekerja dengan baik untuk aplikasi kecil dengan satu server.
Masalah muncul ketika aplikasi perlu di-scale. Bayangkan AsuransiKu sudah berkembang dan membutuhkan dua server untuk menangani traffic. Load balancer mendistribusikan request ke server A dan B. Nasabah login melalui server A, session tersimpan di file server A. Request berikutnya masuk ke server B yang tidak punya file session tersebut. Nasabah tiba-tiba logged out dan bingung.
Dengan Redis sebagai session store, semua server membaca dan menulis session ke tempat yang sama. Nasabah bisa login di server A, request berikutnya ke server B, dan tetap authenticated karena session ada di Redis yang bisa diakses kedua server.
Konfigurasi dasar sudah kita lakukan di bagian 2. Sekarang mari kita pastikan konfigurasi session sudah optimal untuk aplikasi asuransi.
<?php
// config/session.php
return [
'driver' => env('SESSION_DRIVER', 'redis'),
'lifetime' => env('SESSION_LIFETIME', 10080), // 7 hari dalam menit
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', true), // Encrypt session data
'connection' => env('SESSION_CONNECTION', 'session'),
'cookie' => env('SESSION_COOKIE', 'asuransiku_session'),
'path' => '/',
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE', true), // HTTPS only
'http_only' => true,
'same_site' => 'lax',
];
Untuk aplikasi asuransi, saya set session lifetime 7 hari karena nasabah tidak setiap hari login. Mereka mungkin login seminggu sekali untuk cek status. Session yang terlalu pendek akan membuat mereka harus login ulang terus yang mengganggu experience.
Sekarang buat middleware untuk memperkaya session dengan data yang sering diakses. Daripada query database setiap kali untuk menampilkan informasi di header atau sidebar, kita simpan di session saat login.
php artisan make:middleware EnrichCustomerSession
<?php
// app/Http/Middleware/EnrichCustomerSession.php
namespace App\\Http\\Middleware;
use App\\Models\\Customer;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class EnrichCustomerSession
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()) {
return $next($request);
}
// Cek apakah session sudah di-enrich
$lastEnriched = session('customer_data_enriched_at');
$shouldRefresh = !$lastEnriched ||
now()->diffInMinutes($lastEnriched) > 60;
if ($shouldRefresh) {
$this->enrichSession($request->user());
}
return $next($request);
}
private function enrichSession($user): void
{
$customer = Customer::where('user_id', $user->id)->first();
if (!$customer) {
return;
}
// Data yang sering ditampilkan di UI
$activePolicies = $customer->policies()
->where('status', 'active')
->count();
$pendingClaims = $customer->policies()
->join('claims', 'policies.id', '=', 'claims.policy_id')
->whereIn('claims.status', ['submitted', 'reviewing'])
->count();
$totalCoverage = $customer->policies()
->where('status', 'active')
->sum('coverage_amount');
// Simpan ke session
session([
'customer_id' => $customer->id,
'customer_name' => $customer->name,
'customer_segment' => $this->determineSegment($totalCoverage),
'active_policies_count' => $activePolicies,
'pending_claims_count' => $pendingClaims,
'total_coverage' => $totalCoverage,
'customer_data_enriched_at' => now(),
]);
}
private function determineSegment(float $totalCoverage): string
{
if ($totalCoverage >= 5000000000) {
return 'platinum';
} elseif ($totalCoverage >= 1000000000) {
return 'gold';
} elseif ($totalCoverage >= 500000000) {
return 'silver';
}
return 'bronze';
}
}
Register middleware di bootstrap/app.php.
<?php
// bootstrap/app.php
use Illuminate\\Foundation\\Application;
use Illuminate\\Foundation\\Configuration\\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\\App\\Http\\Middleware\\EnrichCustomerSession::class,
]);
})
->create();
Sekarang di controller atau view, kita bisa langsung akses data dari session tanpa query database.
<?php
// app/Http/Controllers/CustomerDashboardController.php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
class CustomerDashboardController extends Controller
{
public function index(Request $request): JsonResponse
{
// Data dari session, tidak perlu query database
return response()->json([
'success' => true,
'data' => [
'customer' => [
'id' => session('customer_id'),
'name' => session('customer_name'),
'segment' => session('customer_segment'),
],
'summary' => [
'active_policies' => session('active_policies_count', 0),
'pending_claims' => session('pending_claims_count', 0),
'total_coverage' => session('total_coverage', 0),
'total_coverage_formatted' => 'Rp ' . number_format(
session('total_coverage', 0), 0, ',', '.'
),
],
'segment_benefits' => $this->getSegmentBenefits(session('customer_segment')),
],
]);
}
private function getSegmentBenefits(string $segment): array
{
return match($segment) {
'platinum' => [
'Priority claim processing',
'Dedicated relationship manager',
'Exclusive health check-up',
'Airport lounge access',
],
'gold' => [
'Fast-track claim processing',
'Annual health check-up',
'Premium customer service',
],
'silver' => [
'Standard claim processing',
'Birthday rewards',
],
default => [
'Standard benefits',
],
};
}
}
Tambahkan route untuk dashboard.
<?php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/dashboard', [CustomerDashboardController::class, 'index']);
});
Fitur penting lainnya adalah kemampuan logout dari semua device. Karena session tersimpan di Redis dengan format yang bisa kita query, kita bisa invalidate semua session user.
Pertama, kita perlu menyimpan mapping antara user dan session ID. Buat listener untuk event Login.
php artisan make:listener TrackUserSession
<?php
// app/Listeners/TrackUserSession.php
namespace App\\Listeners;
use Illuminate\\Auth\\Events\\Login;
use Illuminate\\Support\\Facades\\Redis;
class TrackUserSession
{
public function handle(Login $event): void
{
$userId = $event->user->id;
$sessionId = session()->getId();
// Simpan session ID ke Redis Set untuk user ini
$key = "user_sessions:{$userId}";
Redis::sadd($key, $sessionId);
// Set expiration sama dengan session lifetime
$lifetime = config('session.lifetime') * 60;
Redis::expire($key, $lifetime);
}
}
Register listener di EventServiceProvider atau menggunakan attribute.
<?php
// app/Providers/EventServiceProvider.php
namespace App\\Providers;
use App\\Listeners\\TrackUserSession;
use Illuminate\\Auth\\Events\\Login;
use Illuminate\\Foundation\\Support\\Providers\\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
Login::class => [
TrackUserSession::class,
],
];
}
Sekarang buat method di model User untuk logout dari semua device.
<?php
// app/Models/User.php
namespace App\\Models;
use Illuminate\\Foundation\\Auth\\User as Authenticatable;
use Illuminate\\Support\\Facades\\Redis;
class User extends Authenticatable
{
// ... existing code
public function logoutAllDevices(): int
{
$key = "user_sessions:{$this->id}";
// Ambil semua session IDs
$sessionIds = Redis::smembers($key);
if (empty($sessionIds)) {
return 0;
}
$prefix = config('cache.prefix', 'laravel_cache_');
$count = 0;
// Hapus setiap session dari Redis
foreach ($sessionIds as $sessionId) {
$sessionKey = $prefix . 'session:' . $sessionId;
if (Redis::del($sessionKey)) {
$count++;
}
}
// Hapus tracking set
Redis::del($key);
return $count;
}
public function getActiveSessionsCount(): int
{
$key = "user_sessions:{$this->id}";
return (int) Redis::scard($key);
}
}
Buat controller untuk security settings.
<?php
// app/Http/Controllers/SecurityController.php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
class SecurityController extends Controller
{
public function getActiveSessions(Request $request): JsonResponse
{
$user = $request->user();
return response()->json([
'success' => true,
'data' => [
'active_sessions' => $user->getActiveSessionsCount(),
'current_session' => session()->getId(),
],
]);
}
public function logoutAllDevices(Request $request): JsonResponse
{
$user = $request->user();
$currentSession = session()->getId();
// Logout semua device
$count = $user->logoutAllDevices();
// Regenerate current session agar tetap login di device ini
session()->regenerate();
return response()->json([
'success' => true,
'message' => "Berhasil logout dari {$count} device lain",
'data' => [
'logged_out_sessions' => $count,
],
]);
}
public function logoutOtherDevices(Request $request): JsonResponse
{
$request->validate([
'password' => 'required|current_password',
]);
$user = $request->user();
$currentSession = session()->getId();
$key = "user_sessions:{$user->id}";
// Ambil semua session kecuali current
$sessionIds = Redis::smembers($key);
$count = 0;
$prefix = config('cache.prefix', 'laravel_cache_');
foreach ($sessionIds as $sessionId) {
if ($sessionId !== $currentSession) {
$sessionKey = $prefix . 'session:' . $sessionId;
if (Redis::del($sessionKey)) {
Redis::srem($key, $sessionId);
$count++;
}
}
}
return response()->json([
'success' => true,
'message' => "Berhasil logout dari {$count} device lain",
]);
}
}
Tambahkan routes.
<?php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/security/sessions', [SecurityController::class, 'getActiveSessions']);
Route::post('/security/logout-all', [SecurityController::class, 'logoutAllDevices']);
Route::post('/security/logout-others', [SecurityController::class, 'logoutOtherDevices']);
});
Untuk melihat session data di Redis, gunakan redis-cli.
redis-cli
# Pilih database session (database 2 sesuai config)
SELECT 2
# Lihat semua session keys
KEYS *session*
# Lihat isi session tertentu (akan ter-serialize)
GET laravel_cache_session:abc123...
# Lihat user sessions tracking
SMEMBERS user_sessions:1
Buat command untuk membersihkan session tracking yang expired.
php artisan make:command CleanupSessionTracking
<?php
// app/Console/Commands/CleanupSessionTracking.php
namespace App\\Console\\Commands;
use App\\Models\\User;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Redis;
class CleanupSessionTracking extends Command
{
protected $signature = 'session:cleanup-tracking';
protected $description = 'Cleanup expired session tracking data';
public function handle(): int
{
$this->info('Cleaning up session tracking...');
$users = User::all(['id']);
$cleaned = 0;
foreach ($users as $user) {
$key = "user_sessions:{$user->id}";
$sessionIds = Redis::smembers($key);
$prefix = config('cache.prefix', 'laravel_cache_');
foreach ($sessionIds as $sessionId) {
$sessionKey = $prefix . 'session:' . $sessionId;
// Cek apakah session masih ada
if (!Redis::exists($sessionKey)) {
Redis::srem($key, $sessionId);
$cleaned++;
}
}
}
$this->info("Cleaned {$cleaned} expired session tracking entries");
return Command::SUCCESS;
}
}
Schedule command untuk berjalan periodik.
<?php
// routes/console.php
use Illuminate\\Support\\Facades\\Schedule;
Schedule::command('session:cleanup-tracking')->hourly();
Dengan Redis session, aplikasi AsuransiKu siap untuk horizontal scaling. Nasabah bisa login dari berbagai device dengan experience yang konsisten. Fitur security seperti logout from all devices memberikan kontrol lebih kepada nasabah untuk mengamankan akun mereka. Session enrichment mengurangi query database untuk data yang sering ditampilkan.
Di bagian selanjutnya, kita akan mengimplementasikan Redis Queue untuk memproses task berat seperti underwriting dan pengiriman dokumen polis secara background.
Bagian 7: Queue dengan Redis untuk Background Processing
Queue dengan Redis memungkinkan aplikasi AsuransiKu memproses task berat seperti underwriting, pengiriman dokumen polis, dan notifikasi klaim secara background tanpa membuat nasabah menunggu. Dengan arsitektur queue yang tepat, response time tetap cepat meskipun ada proses kompleks yang harus dijalankan. Redis queue juga mendukung prioritas, retry mechanism, dan monitoring melalui Laravel Horizon.
Ketika nasabah submit pengajuan polis baru, ada banyak proses yang harus dilakukan. Validasi data nasabah, pengecekan kelengkapan dokumen, proses underwriting untuk menentukan apakah pengajuan diterima atau ditolak, generate nomor polis, pembuatan dokumen PDF, dan pengiriman email konfirmasi. Jika semua ini dijalankan synchronous, nasabah harus menunggu 10-30 detik sampai semua proses selesai.
Dengan queue, alurnya menjadi berbeda. Nasabah submit pengajuan, sistem menyimpan data dan langsung return response dalam hitungan milidetik dengan pesan "Pengajuan Anda sedang diproses". Di background, worker mengambil job dari queue dan memproses satu per satu. Nasabah bisa melanjutkan aktivitas lain dan akan mendapat notifikasi ketika proses selesai.
Konfigurasi queue sudah kita lakukan di bagian 2 dengan QUEUE_CONNECTION=redis. Sekarang kita buat struktur queue dengan prioritas berbeda.
<?php
// config/queue.php
return [
'default' => env('QUEUE_CONNECTION', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => true,
],
],
'batching' => [
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'job_batches',
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs',
],
];
Buat migration untuk job batches jika belum ada.
php artisan queue:batches-table
php artisan migrate
Sekarang buat job untuk memproses pengajuan polis.
php artisan make:job ProcessPolicyApplication
<?php
// app/Jobs/ProcessPolicyApplication.php
namespace App\\Jobs;
use App\\Models\\Policy;
use App\\Models\\Customer;
use App\\Events\\PolicyStatusUpdated;
use App\\Jobs\\GeneratePolicyDocument;
use App\\Jobs\\SendPolicyEmail;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Log;
class ProcessPolicyApplication implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public int $timeout = 120;
public function __construct(
public Policy $policy
) {
$this->onQueue('high');
}
public function handle(): void
{
Log::info("Processing policy application: {$this->policy->policy_number}");
// Update status ke under_review
$this->updateStatus('under_review', 'Pengajuan sedang ditinjau oleh tim kami');
// Step 1: Validasi data nasabah
if (!$this->validateCustomerData()) {
$this->rejectPolicy('Data nasabah tidak lengkap atau tidak valid');
return;
}
// Step 2: Validasi dokumen
if (!$this->validateDocuments()) {
$this->updateStatus('pending', 'Dokumen pendukung belum lengkap');
$this->notifyIncompleteDocuments();
return;
}
// Step 3: Proses underwriting
$underwritingResult = $this->performUnderwriting();
if (!$underwritingResult['approved']) {
$this->rejectPolicy($underwritingResult['reason']);
return;
}
// Step 4: Approve polis
$this->approvePolicy();
// Step 5: Dispatch job berikutnya
GeneratePolicyDocument::dispatch($this->policy)->onQueue('default');
}
private function validateCustomerData(): bool
{
$customer = $this->policy->customer;
$requiredFields = ['name', 'email', 'phone', 'date_of_birth', 'id_number', 'address'];
foreach ($requiredFields as $field) {
if (empty($customer->$field)) {
Log::warning("Customer {$customer->id} missing field: {$field}");
return false;
}
}
// Validasi usia
$age = $customer->date_of_birth->age;
$product = $this->policy->insuranceProduct;
if ($age < $product->min_age || $age > $product->max_age) {
Log::warning("Customer age {$age} out of range for product");
return false;
}
return true;
}
private function validateDocuments(): bool
{
// Simplified: check if required documents exist
// In real app, this would check uploaded files
return true;
}
private function performUnderwriting(): array
{
$customer = $this->policy->customer;
$coverage = $this->policy->coverage_amount;
// Simplified underwriting logic
// In real app, this would involve complex risk assessment
// High coverage requires additional check
if ($coverage > 1000000000) {
// Simulate additional verification
sleep(2);
}
// Calculate risk score based on age and coverage
$age = $customer->date_of_birth->age;
$riskScore = ($age / 100) + ($coverage / 10000000000);
if ($riskScore > 0.8) {
return [
'approved' => false,
'reason' => 'Risk score melebihi batas yang diizinkan',
'score' => $riskScore,
];
}
return [
'approved' => true,
'reason' => null,
'score' => $riskScore,
];
}
private function updateStatus(string $status, string $message): void
{
$oldStatus = $this->policy->status;
$this->policy->update(['status' => $status]);
PolicyStatusUpdated::dispatch($this->policy, $oldStatus, $status, $message);
Log::info("Policy {$this->policy->policy_number} status: {$oldStatus} -> {$status}");
}
private function approvePolicy(): void
{
$this->policy->update([
'status' => 'active',
'approved_at' => now(),
]);
PolicyStatusUpdated::dispatch(
$this->policy,
'under_review',
'active',
'Selamat! Pengajuan polis Anda telah disetujui'
);
Log::info("Policy {$this->policy->policy_number} approved");
}
private function rejectPolicy(string $reason): void
{
$this->policy->update([
'status' => 'cancelled',
'notes' => $reason,
]);
PolicyStatusUpdated::dispatch(
$this->policy,
$this->policy->status,
'cancelled',
"Pengajuan ditolak: {$reason}"
);
Log::warning("Policy {$this->policy->policy_number} rejected: {$reason}");
}
private function notifyIncompleteDocuments(): void
{
// Dispatch notification job
SendNotification::dispatch(
$this->policy->customer,
'incomplete_documents',
['policy_number' => $this->policy->policy_number]
)->onQueue('notifications');
}
public function failed(\\Throwable $exception): void
{
Log::error("Failed processing policy {$this->policy->policy_number}: {$exception->getMessage()}");
$this->policy->update([
'status' => 'pending',
'notes' => 'Terjadi kesalahan sistem, silakan coba lagi',
]);
// Notify admin about failure
SendNotification::dispatch(
null, // admin notification
'job_failed',
[
'job' => 'ProcessPolicyApplication',
'policy_number' => $this->policy->policy_number,
'error' => $exception->getMessage(),
]
)->onQueue('notifications');
}
}
Buat job untuk generate dokumen polis.
php artisan make:job GeneratePolicyDocument
<?php
// app/Jobs/GeneratePolicyDocument.php
namespace App\\Jobs;
use App\\Models\\Policy;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Log;
use Illuminate\\Support\\Facades\\Storage;
class GeneratePolicyDocument implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 300; // 5 menit untuk generate PDF
public function __construct(
public Policy $policy
) {
$this->onQueue('default');
}
public function handle(): void
{
Log::info("Generating document for policy: {$this->policy->policy_number}");
$customer = $this->policy->customer;
$product = $this->policy->insuranceProduct;
// Generate PDF content
$pdfContent = $this->generatePdfContent();
// Simpan ke storage
$filename = "policies/{$this->policy->policy_number}.pdf";
Storage::put($filename, $pdfContent);
// Update policy dengan link dokumen
$this->policy->update([
'document_url' => $filename,
]);
Log::info("Document generated: {$filename}");
// Dispatch email job
SendPolicyEmail::dispatch($this->policy)->onQueue('emails');
}
private function generatePdfContent(): string
{
// Simplified: In real app, use library like DomPDF or Snappy
$customer = $this->policy->customer;
$product = $this->policy->insuranceProduct;
$content = "
POLIS ASURANSI JIWA
===================
Nomor Polis: {$this->policy->policy_number}
DATA TERTANGGUNG
Nama: {$customer->name}
Tanggal Lahir: {$customer->date_of_birth->format('d F Y')}
No. KTP: {$customer->id_number}
DATA POLIS
Produk: {$product->name}
Uang Pertanggungan: Rp " . number_format($this->policy->coverage_amount, 0, ',', '.') . "
Premi Tahunan: Rp " . number_format($this->policy->annual_premium, 0, ',', '.') . "
Tanggal Mulai: {$this->policy->start_date->format('d F Y')}
Tanggal Berakhir: {$this->policy->end_date->format('d F Y')}
Dokumen ini adalah bukti sah kepesertaan asuransi.
";
return $content;
}
public function failed(\\Throwable $exception): void
{
Log::error("Failed generating document for {$this->policy->policy_number}: {$exception->getMessage()}");
}
}
Buat job untuk mengirim email.
php artisan make:job SendPolicyEmail
<?php
// app/Jobs/SendPolicyEmail.php
namespace App\\Jobs;
use App\\Models\\Policy;
use App\\Mail\\PolicyApproved;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Log;
use Illuminate\\Support\\Facades\\Mail;
class SendPolicyEmail implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 120; // Retry after 2 minutes
public function __construct(
public Policy $policy
) {
$this->onQueue('emails');
}
public function handle(): void
{
$customer = $this->policy->customer;
Log::info("Sending policy email to: {$customer->email}");
Mail::to($customer->email)->send(new PolicyApproved($this->policy));
Log::info("Policy email sent successfully");
}
public function failed(\\Throwable $exception): void
{
Log::error("Failed sending email for {$this->policy->policy_number}: {$exception->getMessage()}");
}
}
Buat job generic untuk notifikasi.
php artisan make:job SendNotification
<?php
// app/Jobs/SendNotification.php
namespace App\\Jobs;
use App\\Models\\Customer;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Log;
class SendNotification implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public ?Customer $customer,
public string $type,
public array $data = []
) {
$this->onQueue('notifications');
}
public function handle(): void
{
Log::info("Sending notification: {$this->type}", $this->data);
// In real app, implement different notification channels
match($this->type) {
'incomplete_documents' => $this->sendIncompleteDocumentsNotification(),
'policy_approved' => $this->sendPolicyApprovedNotification(),
'claim_status_update' => $this->sendClaimStatusNotification(),
'job_failed' => $this->sendAdminAlert(),
default => Log::warning("Unknown notification type: {$this->type}"),
};
}
private function sendIncompleteDocumentsNotification(): void
{
// Send email/SMS to customer about incomplete documents
Log::info("Notifying customer about incomplete documents");
}
private function sendPolicyApprovedNotification(): void
{
// Send congratulations notification
Log::info("Sending policy approval notification");
}
private function sendClaimStatusNotification(): void
{
// Send claim status update
Log::info("Sending claim status notification");
}
private function sendAdminAlert(): void
{
// Send alert to admin (Slack, email, etc)
Log::warning("Admin alert: Job failed", $this->data);
}
}
Buat job untuk memproses klaim.
php artisan make:job ProcessClaimRequest
<?php
// app/Jobs/ProcessClaimRequest.php
namespace App\\Jobs;
use App\\Models\\Claim;
use App\\Events\\ClaimStatusUpdated;
use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Log;
class ProcessClaimRequest implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 180;
public function __construct(
public Claim $claim
) {
$this->onQueue('high');
}
public function handle(): void
{
Log::info("Processing claim: {$this->claim->claim_number}");
$policy = $this->claim->policy;
// Step 1: Validasi polis aktif
if ($policy->status !== 'active') {
$this->rejectClaim('Polis tidak dalam status aktif');
return;
}
// Step 2: Validasi coverage
if ($this->claim->claim_amount > $policy->coverage_amount) {
$this->rejectClaim('Jumlah klaim melebihi uang pertanggungan');
return;
}
// Step 3: Update status ke reviewing
$this->updateStatus('reviewing', 'Klaim sedang dalam proses review');
// Step 4: Validasi dokumen pendukung
if (!$this->validateClaimDocuments()) {
$this->requestAdditionalDocuments();
return;
}
// Step 5: Auto-approve untuk klaim kecil
if ($this->claim->claim_amount <= 50000000) {
$this->approveClaim();
return;
}
// Step 6: Untuk klaim besar, perlu review manual
$this->markForManualReview();
}
private function validateClaimDocuments(): bool
{
// Check if required documents are present
$documents = $this->claim->documents ?? [];
$requiredDocs = match($this->claim->claim_type) {
'death' => ['death_certificate', 'id_card', 'policy_document'],
'hospital' => ['hospital_bill', 'medical_record', 'id_card'],
'critical_illness' => ['medical_diagnosis', 'doctor_letter', 'id_card'],
default => ['id_card'],
};
foreach ($requiredDocs as $doc) {
if (!isset($documents[$doc])) {
return false;
}
}
return true;
}
private function updateStatus(string $status, string $message): void
{
$oldStatus = $this->claim->status;
$this->claim->update(['status' => $status]);
ClaimStatusUpdated::dispatch($this->claim, $oldStatus, $status, $message);
Log::info("Claim {$this->claim->claim_number} status: {$oldStatus} -> {$status}");
}
private function approveClaim(): void
{
$this->claim->update([
'status' => 'approved',
'reviewed_at' => now(),
]);
ClaimStatusUpdated::dispatch(
$this->claim,
'reviewing',
'approved',
'Klaim Anda telah disetujui dan akan segera diproses pembayarannya'
);
// Queue payment processing
// ProcessClaimPayment::dispatch($this->claim)->onQueue('default');
Log::info("Claim {$this->claim->claim_number} approved");
}
private function rejectClaim(string $reason): void
{
$this->claim->update([
'status' => 'rejected',
'rejection_reason' => $reason,
'reviewed_at' => now(),
]);
ClaimStatusUpdated::dispatch(
$this->claim,
$this->claim->status,
'rejected',
"Klaim ditolak: {$reason}"
);
Log::warning("Claim {$this->claim->claim_number} rejected: {$reason}");
}
private function requestAdditionalDocuments(): void
{
$this->updateStatus('submitted', 'Dokumen pendukung belum lengkap, mohon lengkapi dokumen yang diperlukan');
SendNotification::dispatch(
$this->claim->policy->customer,
'incomplete_claim_documents',
['claim_number' => $this->claim->claim_number]
)->onQueue('notifications');
}
private function markForManualReview(): void
{
$this->updateStatus('reviewing', 'Klaim sedang ditinjau oleh tim kami');
// Notify underwriter for manual review
SendNotification::dispatch(
null,
'claim_needs_review',
[
'claim_number' => $this->claim->claim_number,
'amount' => $this->claim->claim_amount,
]
)->onQueue('notifications');
Log::info("Claim {$this->claim->claim_number} marked for manual review");
}
public function failed(\\Throwable $exception): void
{
Log::error("Failed processing claim {$this->claim->claim_number}: {$exception->getMessage()}");
}
}
Buat controller untuk menangani submission.
<?php
// app/Http/Controllers/PolicyApplicationController.php
namespace App\\Http\\Controllers;
use App\\Jobs\\ProcessPolicyApplication;
use App\\Models\\Customer;
use App\\Models\\InsuranceProduct;
use App\\Models\\Policy;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Str;
class PolicyApplicationController extends Controller
{
public function submit(Request $request): JsonResponse
{
$validated = $request->validate([
'product_id' => 'required|exists:insurance_products,id',
'coverage_amount' => 'required|numeric|min:100000000',
'payment_frequency' => 'required|in:monthly,quarterly,semi_annual,annual',
'customer' => 'required|array',
'customer.name' => 'required|string',
'customer.email' => 'required|email',
'customer.phone' => 'required|string',
'customer.date_of_birth' => 'required|date',
'customer.gender' => 'required|in:male,female',
'customer.id_number' => 'required|string|size:16',
'customer.address' => 'required|string',
'customer.city' => 'required|string',
'customer.province' => 'required|string',
'customer.postal_code' => 'required|string',
]);
// Create or update customer
$customer = Customer::updateOrCreate(
['id_number' => $validated['customer']['id_number']],
$validated['customer']
);
// Get product and calculate premium
$product = InsuranceProduct::findOrFail($validated['product_id']);
// Create policy
$policy = Policy::create([
'customer_id' => $customer->id,
'insurance_product_id' => $product->id,
'policy_number' => 'POL-' . strtoupper(Str::random(10)),
'coverage_amount' => $validated['coverage_amount'],
'annual_premium' => $this->calculatePremium($product, $customer, $validated['coverage_amount']),
'monthly_premium' => $this->calculatePremium($product, $customer, $validated['coverage_amount']) / 12,
'payment_frequency' => $validated['payment_frequency'],
'start_date' => now(),
'end_date' => now()->addYears(10),
'status' => 'pending',
]);
// Dispatch job ke queue
ProcessPolicyApplication::dispatch($policy);
return response()->json([
'success' => true,
'message' => 'Pengajuan polis berhasil diterima dan sedang diproses',
'data' => [
'policy_number' => $policy->policy_number,
'status' => 'pending',
'estimated_processing_time' => '1-2 hari kerja',
],
], 202); // 202 Accepted
}
private function calculatePremium(InsuranceProduct $product, Customer $customer, float $coverage): float
{
// Simplified calculation
// In real app, use PremiumRateService
return $coverage * 0.01;
}
}
Tambahkan route.
<?php
// routes/api.php
Route::post('/apply', [PolicyApplicationController::class, 'submit']);
Untuk menjalankan queue worker, gunakan command berikut.
# Jalankan worker untuk semua queue dengan prioritas
php artisan queue:work redis --queue=high,default,emails,notifications
# Jalankan worker spesifik untuk queue tertentu
php artisan queue:work redis --queue=high --tries=3
# Jalankan dengan verbose output untuk debugging
php artisan queue:work redis --queue=high,default -v
Untuk production, gunakan Supervisor untuk menjaga worker tetap berjalan.
; /etc/supervisor/conf.d/asuransiku-worker.conf
[program:asuransiku-worker-high]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/asuransiku/artisan queue:work redis --queue=high --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/asuransiku/worker-high.log
stopwaitsecs=3600
[program:asuransiku-worker-default]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/asuransiku/artisan queue:work redis --queue=default,emails,notifications --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=4
redirect_stderr=true
stdout_logfile=/var/log/asuransiku/worker-default.log
stopwaitsecs=3600
Untuk monitoring queue, install Laravel Horizon.
composer require laravel/horizon
php artisan horizon:install
Konfigurasi Horizon di config/horizon.php.
<?php
// config/horizon.php
return [
'environments' => [
'production' => [
'supervisor-high' => [
'connection' => 'redis',
'queue' => ['high'],
'balance' => 'auto',
'processes' => 3,
'tries' => 3,
'timeout' => 120,
],
'supervisor-default' => [
'connection' => 'redis',
'queue' => ['default', 'emails', 'notifications'],
'balance' => 'auto',
'processes' => 5,
'tries' => 3,
'timeout' => 90,
],
],
'local' => [
'supervisor-1' => [
'connection' => 'redis',
'queue' => ['high', 'default', 'emails', 'notifications'],
'balance' => 'auto',
'processes' => 3,
'tries' => 3,
],
],
],
];
Jalankan Horizon.
php artisan horizon
Akses dashboard Horizon di /horizon untuk melihat status queue, jobs yang sedang diproses, dan yang gagal.
Dengan Redis queue, aplikasi AsuransiKu bisa menangani proses berat tanpa mengorbankan response time. Nasabah mendapat feedback cepat bahwa pengajuan mereka diterima, sementara proses kompleks berjalan di background. Jika ada job yang gagal, sistem otomatis retry dan admin bisa monitor melalui Horizon.
Di bagian selanjutnya, kita akan mengimplementasikan Rate Limiting dengan Redis untuk melindungi API kalkulator dan endpoint publik lainnya.
Bagian 8: Rate Limiting API dengan Redis
Rate limiting dengan Redis melindungi API AsuransiKu dari abuse, DDoS, dan penggunaan berlebihan. Kalkulator premi yang bisa diakses publik rentan terhadap scraping oleh kompetitor atau bot yang mencoba mengambil semua data rate. Dengan rate limiter yang tepat, kita membatasi jumlah request per periode waktu sambil tetap memberikan experience yang baik untuk pengguna legitimate.
Laravel sudah menyediakan throttle middleware yang secara default menggunakan cache driver. Karena kita sudah konfigurasi CACHE_STORE=redis, rate limiting otomatis menggunakan Redis sebagai backend. Redis sangat cocok untuk rate limiting karena operasi increment dan expire yang atomic, memastikan counter akurat meskipun ada banyak request concurrent.
Buat rate limiter untuk berbagai endpoint di AppServiceProvider.
<?php
// app/Providers/AppServiceProvider.php
namespace App\\Providers;
use App\\Models\\InsuranceProduct;
use App\\Observers\\InsuranceProductObserver;
use Illuminate\\Cache\\RateLimiting\\Limit;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
use Illuminate\\Support\\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
InsuranceProduct::observe(InsuranceProductObserver::class);
$this->configureRateLimiting();
}
protected function configureRateLimiting(): void
{
// Rate limiter untuk API umum
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by(
$request->user()?->id ?: $request->ip()
);
});
// Rate limiter untuk kalkulator premi - lebih ketat karena resource intensive
RateLimiter::for('calculator', function (Request $request) {
// Guest user: 20 request per menit
// Authenticated user: 100 request per menit
if ($request->user()) {
return Limit::perMinute(100)->by($request->user()->id);
}
return Limit::perMinute(20)->by($request->ip());
});
// Rate limiter untuk submit aplikasi - sangat ketat untuk prevent spam
RateLimiter::for('application', function (Request $request) {
// Maksimal 3 aplikasi per jam per IP
return Limit::perHour(3)->by($request->ip());
});
// Rate limiter untuk endpoint sensitif seperti login
RateLimiter::for('auth', function (Request $request) {
return Limit::perMinute(5)->by(
$request->input('email') . '|' . $request->ip()
);
});
// Rate limiter untuk API partner dengan tier berbeda
RateLimiter::for('partner-api', function (Request $request) {
$user = $request->user();
if (!$user) {
return Limit::perMinute(10)->by($request->ip());
}
return match($user->partner_tier ?? 'basic') {
'premium' => Limit::perDay(50000)->by($user->id),
'business' => Limit::perDay(10000)->by($user->id),
'basic' => Limit::perDay(1000)->by($user->id),
default => Limit::perMinute(30)->by($user->id),
};
});
// Rate limiter untuk download dokumen
RateLimiter::for('downloads', function (Request $request) {
return Limit::perHour(50)->by(
$request->user()?->id ?: $request->ip()
);
});
}
}
Terapkan rate limiter ke routes.
<?php
// routes/api.php
use App\\Http\\Controllers\\PolicyApplicationController;
use App\\Http\\Controllers\\PremiumCalculatorController;
use App\\Http\\Controllers\\ProductController;
use App\\Http\\Controllers\\CustomerDashboardController;
use App\\Http\\Controllers\\SecurityController;
use Illuminate\\Support\\Facades\\Route;
// Public routes dengan rate limiting standar
Route::middleware('throttle:api')->group(function () {
Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{slug}', [ProductController::class, 'show']);
});
// Kalkulator dengan rate limiting khusus
Route::middleware('throttle:calculator')->group(function () {
Route::post('/calculate-premium', [PremiumCalculatorController::class, 'calculate']);
Route::post('/available-coverages', [PremiumCalculatorController::class, 'getAvailableCoverages']);
});
// Submit aplikasi dengan rate limiting ketat
Route::middleware('throttle:application')->group(function () {
Route::post('/apply', [PolicyApplicationController::class, 'submit']);
});
// Authenticated routes
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
Route::get('/dashboard', [CustomerDashboardController::class, 'index']);
Route::get('/security/sessions', [SecurityController::class, 'getActiveSessions']);
Route::post('/security/logout-all', [SecurityController::class, 'logoutAllDevices']);
});
Buat custom response ketika rate limit terlampaui dengan pesan yang informatif.
<?php
// app/Exceptions/Handler.php atau bootstrap/app.php
use Illuminate\\Http\\Exceptions\\ThrottleRequestsException;
use Symfony\\Component\\HttpFoundation\\Response;
// Di bootstrap/app.php
->withExceptions(function (Exceptions $exceptions) {
$exceptions->render(function (ThrottleRequestsException $e, Request $request) {
if ($request->expectsJson()) {
$retryAfter = $e->getHeaders()['Retry-After'] ?? 60;
return response()->json([
'success' => false,
'message' => 'Terlalu banyak permintaan. Silakan coba lagi nanti.',
'error_code' => 'RATE_LIMIT_EXCEEDED',
'data' => [
'retry_after_seconds' => (int) $retryAfter,
'retry_after_human' => $retryAfter > 60
? ceil($retryAfter / 60) . ' menit'
: $retryAfter . ' detik',
],
], Response::HTTP_TOO_MANY_REQUESTS);
}
});
})
Untuk monitoring dan analisis, buat middleware yang mencatat rate limit hits.
php artisan make:middleware LogRateLimitHits
<?php
// app/Http/Middleware/LogRateLimitHits.php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Log;
use Illuminate\\Support\\Facades\\Redis;
use Symfony\\Component\\HttpFoundation\\Response;
class LogRateLimitHits
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Log jika mendekati atau mencapai limit
$remaining = $response->headers->get('X-RateLimit-Remaining');
$limit = $response->headers->get('X-RateLimit-Limit');
if ($remaining !== null && $limit !== null) {
$usagePercent = (1 - ($remaining / $limit)) * 100;
// Log jika usage > 80%
if ($usagePercent > 80) {
$identifier = $request->user()?->id ?? $request->ip();
Log::warning('High rate limit usage', [
'identifier' => $identifier,
'endpoint' => $request->path(),
'usage_percent' => round($usagePercent, 1),
'remaining' => $remaining,
'limit' => $limit,
]);
// Track di Redis untuk analisis
$this->trackHighUsage($identifier, $request->path());
}
}
// Log jika rate limit exceeded
if ($response->getStatusCode() === 429) {
$identifier = $request->user()?->id ?? $request->ip();
Log::warning('Rate limit exceeded', [
'identifier' => $identifier,
'endpoint' => $request->path(),
'user_agent' => $request->userAgent(),
]);
$this->trackRateLimitHit($identifier, $request->path());
}
return $response;
}
private function trackHighUsage(string $identifier, string $endpoint): void
{
$key = 'rate_limit:high_usage:' . date('Y-m-d');
Redis::hincrby($key, "{$identifier}:{$endpoint}", 1);
Redis::expire($key, 86400 * 7); // Keep 7 days
}
private function trackRateLimitHit(string $identifier, string $endpoint): void
{
$key = 'rate_limit:exceeded:' . date('Y-m-d');
Redis::hincrby($key, "{$identifier}:{$endpoint}", 1);
Redis::expire($key, 86400 * 7);
}
}
Register middleware di bootstrap/app.php.
<?php
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->api(append: [
\\App\\Http\\Middleware\\LogRateLimitHits::class,
]);
})
Buat command untuk menganalisis rate limit hits.
php artisan make:command AnalyzeRateLimits
<?php
// app/Console/Commands/AnalyzeRateLimits.php
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Redis;
class AnalyzeRateLimits extends Command
{
protected $signature = 'rate-limit:analyze {--days=7}';
protected $description = 'Analyze rate limit hits and high usage patterns';
public function handle(): int
{
$days = (int) $this->option('days');
$this->info("Analyzing rate limits for the past {$days} days...\\n");
$exceededData = [];
$highUsageData = [];
for ($i = 0; $i < $days; $i++) {
$date = date('Y-m-d', strtotime("-{$i} days"));
// Get exceeded data
$exceededKey = "rate_limit:exceeded:{$date}";
$exceeded = Redis::hgetall($exceededKey);
foreach ($exceeded as $key => $count) {
if (!isset($exceededData[$key])) {
$exceededData[$key] = 0;
}
$exceededData[$key] += (int) $count;
}
// Get high usage data
$highUsageKey = "rate_limit:high_usage:{$date}";
$highUsage = Redis::hgetall($highUsageKey);
foreach ($highUsage as $key => $count) {
if (!isset($highUsageData[$key])) {
$highUsageData[$key] = 0;
}
$highUsageData[$key] += (int) $count;
}
}
// Display rate limit exceeded
$this->info('=== Rate Limit Exceeded ===');
if (empty($exceededData)) {
$this->line('No rate limit exceeded events found.');
} else {
arsort($exceededData);
$rows = [];
foreach (array_slice($exceededData, 0, 20, true) as $key => $count) {
[$identifier, $endpoint] = explode(':', $key, 2);
$rows[] = [$identifier, $endpoint, $count];
}
$this->table(['Identifier', 'Endpoint', 'Count'], $rows);
}
$this->newLine();
// Display high usage
$this->info('=== High Usage (>80%) ===');
if (empty($highUsageData)) {
$this->line('No high usage events found.');
} else {
arsort($highUsageData);
$rows = [];
foreach (array_slice($highUsageData, 0, 20, true) as $key => $count) {
[$identifier, $endpoint] = explode(':', $key, 2);
$rows[] = [$identifier, $endpoint, $count];
}
$this->table(['Identifier', 'Endpoint', 'Count'], $rows);
}
// Summary
$this->newLine();
$this->info('=== Summary ===');
$this->line('Total exceeded events: ' . array_sum($exceededData));
$this->line('Total high usage events: ' . array_sum($highUsageData));
$this->line('Unique identifiers with exceeded: ' . count($exceededData));
return Command::SUCCESS;
}
}
Untuk kasus di mana kita perlu bypass rate limit untuk internal services atau admin, buat middleware khusus.
php artisan make:middleware BypassRateLimitForAdmin
<?php
// app/Http/Middleware/BypassRateLimitForAdmin.php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;
class BypassRateLimitForAdmin
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
// Bypass rate limit untuk admin atau internal API key
if ($user && $user->hasRole('admin')) {
// Remove throttle headers
$response = $next($request);
$response->headers->remove('X-RateLimit-Limit');
$response->headers->remove('X-RateLimit-Remaining');
return $response;
}
// Check for internal API key
$internalKey = $request->header('X-Internal-API-Key');
if ($internalKey && $internalKey === config('app.internal_api_key')) {
return $next($request);
}
return $next($request);
}
}
Buat endpoint untuk melihat status rate limit sendiri.
<?php
// app/Http/Controllers/RateLimitController.php
namespace App\\Http\\Controllers;
use Illuminate\\Http\\JsonResponse;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\RateLimiter;
class RateLimitController extends Controller
{
public function status(Request $request): JsonResponse
{
$user = $request->user();
$identifier = $user?->id ?? $request->ip();
$limiters = ['api', 'calculator', 'application'];
$status = [];
foreach ($limiters as $limiter) {
$key = $limiter . ':' . $identifier;
$status[$limiter] = [
'attempts' => RateLimiter::attempts($key),
'remaining' => RateLimiter::remaining($key, $this->getLimitFor($limiter, $user)),
'reset_at' => RateLimiter::availableIn($key),
];
}
return response()->json([
'success' => true,
'data' => [
'identifier' => $user ? "user:{$user->id}" : "ip:{$request->ip()}",
'limits' => $status,
],
]);
}
private function getLimitFor(string $limiter, $user): int
{
return match($limiter) {
'api' => 60,
'calculator' => $user ? 100 : 20,
'application' => 3,
default => 60,
};
}
}
Tambahkan route.
<?php
// routes/api.php
Route::get('/rate-limit/status', [RateLimitController::class, 'status']);
Untuk melindungi dari distributed attacks, implementasikan juga global rate limit berdasarkan endpoint.
<?php
// app/Http/Middleware/GlobalEndpointRateLimit.php
namespace App\\Http\\Middleware;
use Closure;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\Redis;
use Symfony\\Component\\HttpFoundation\\Response;
class GlobalEndpointRateLimit
{
public function handle(Request $request, Closure $next, int $maxRequests = 1000): Response
{
$endpoint = $request->path();
$key = "global_rate_limit:{$endpoint}:" . floor(time() / 60);
$current = (int) Redis::incr($key);
if ($current === 1) {
Redis::expire($key, 60);
}
if ($current > $maxRequests) {
return response()->json([
'success' => false,
'message' => 'Server sedang sibuk. Silakan coba lagi dalam beberapa saat.',
'error_code' => 'SERVER_BUSY',
], Response::HTTP_SERVICE_UNAVAILABLE);
}
$response = $next($request);
$response->headers->set('X-Global-RateLimit-Limit', $maxRequests);
$response->headers->set('X-Global-RateLimit-Remaining', max(0, $maxRequests - $current));
return $response;
}
}
Terapkan ke routes yang perlu dilindungi.
<?php
// routes/api.php
Route::middleware(['throttle:calculator', 'global.ratelimit:500'])->group(function () {
Route::post('/calculate-premium', [PremiumCalculatorController::class, 'calculate']);
});
Dengan implementasi rate limiting yang komprehensif, API AsuransiKu terlindungi dari berbagai jenis abuse. Pengguna legitimate tetap bisa menggunakan layanan dengan nyaman karena limit yang reasonable. Sistem monitoring membantu mengidentifikasi pattern yang mencurigakan. Admin dan internal services bisa bypass limit ketika diperlukan.
Di bagian selanjutnya, kita akan mengimplementasikan fitur real-time dengan Redis Pub/Sub untuk notifikasi status pengajuan dan klaim.
Bagian 9: Real-time Features dengan Redis Pub/Sub
Fitur real-time dengan Redis Pub/Sub memungkinkan aplikasi AsuransiKu mengirim notifikasi instan ke nasabah ketika status pengajuan polis atau klaim berubah. Nasabah tidak perlu refresh halaman berulang kali untuk mengetahui update terbaru. Dengan Laravel Broadcasting dan Redis sebagai driver, kita bisa membangun experience yang modern dan responsif.
Bayangkan nasabah mengajukan polis dan membuka dashboard untuk menunggu kabar. Tanpa real-time notification, mereka harus refresh halaman setiap beberapa menit untuk cek status. Dengan Redis Pub/Sub, begitu tim underwriting approve pengajuan, notifikasi langsung muncul di browser nasabah dalam hitungan detik. Ini memberikan experience yang jauh lebih baik dan mengurangi anxiety nasabah.
Arsitektur real-time di Laravel menggunakan Broadcasting. Ketika event di-dispatch, Laravel publish message ke Redis channel. WebSocket server seperti Laravel Reverb, Soketi, atau Pusher subscribe ke Redis dan forward message ke connected clients. Di browser, Laravel Echo library receive message dan trigger callback untuk update UI.
Mulai dengan konfigurasi broadcasting. Update file .env.
BROADCAST_CONNECTION=redis
Pastikan config/broadcasting.php sudah terkonfigurasi dengan benar.
<?php
// config/broadcasting.php
return [
'default' => env('BROADCAST_CONNECTION', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
],
];
Buat event untuk notifikasi perubahan status polis.
php artisan make:event PolicyStatusUpdated
<?php
// app/Events/PolicyStatusUpdated.php
namespace App\\Events;
use App\\Models\\Policy;
use Illuminate\\Broadcasting\\Channel;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class PolicyStatusUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Policy $policy,
public string $oldStatus,
public string $newStatus,
public string $message
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('customer.' . $this->policy->customer_id),
];
}
public function broadcastAs(): string
{
return 'policy.status.updated';
}
public function broadcastWith(): array
{
return [
'policy_id' => $this->policy->id,
'policy_number' => $this->policy->policy_number,
'product_name' => $this->policy->insuranceProduct->name,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
'status_label' => $this->policy->status_name,
'message' => $this->message,
'timestamp' => now()->toISOString(),
'icon' => $this->getStatusIcon(),
'color' => $this->getStatusColor(),
];
}
private function getStatusIcon(): string
{
return match($this->newStatus) {
'active' => 'check-circle',
'under_review' => 'clock',
'pending' => 'alert-circle',
'cancelled' => 'x-circle',
default => 'info',
};
}
private function getStatusColor(): string
{
return match($this->newStatus) {
'active' => 'green',
'under_review' => 'blue',
'pending' => 'yellow',
'cancelled' => 'red',
default => 'gray',
};
}
}
Buat event untuk notifikasi perubahan status klaim.
php artisan make:event ClaimStatusUpdated
<?php
// app/Events/ClaimStatusUpdated.php
namespace App\\Events;
use App\\Models\\Claim;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class ClaimStatusUpdated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public Claim $claim,
public string $oldStatus,
public string $newStatus,
public string $message
) {}
public function broadcastOn(): array
{
$customerId = $this->claim->policy->customer_id;
return [
new PrivateChannel('customer.' . $customerId),
];
}
public function broadcastAs(): string
{
return 'claim.status.updated';
}
public function broadcastWith(): array
{
return [
'claim_id' => $this->claim->id,
'claim_number' => $this->claim->claim_number,
'claim_type' => $this->claim->claim_type_name,
'policy_number' => $this->claim->policy->policy_number,
'old_status' => $this->oldStatus,
'new_status' => $this->newStatus,
'status_label' => $this->getStatusLabel(),
'message' => $this->message,
'claim_amount' => $this->claim->claim_amount,
'claim_amount_formatted' => 'Rp ' . number_format($this->claim->claim_amount, 0, ',', '.'),
'timestamp' => now()->toISOString(),
'icon' => $this->getStatusIcon(),
'color' => $this->getStatusColor(),
];
}
private function getStatusLabel(): string
{
return match($this->newStatus) {
'submitted' => 'Diajukan',
'reviewing' => 'Sedang Ditinjau',
'approved' => 'Disetujui',
'rejected' => 'Ditolak',
'paid' => 'Telah Dibayar',
default => $this->newStatus,
};
}
private function getStatusIcon(): string
{
return match($this->newStatus) {
'approved', 'paid' => 'check-circle',
'reviewing' => 'clock',
'submitted' => 'file-text',
'rejected' => 'x-circle',
default => 'info',
};
}
private function getStatusColor(): string
{
return match($this->newStatus) {
'approved', 'paid' => 'green',
'reviewing' => 'blue',
'submitted' => 'gray',
'rejected' => 'red',
default => 'gray',
};
}
}
Buat event untuk notifikasi umum yang bisa digunakan berbagai keperluan.
php artisan make:event CustomerNotification
<?php
// app/Events/CustomerNotification.php
namespace App\\Events;
use App\\Models\\Customer;
use Illuminate\\Broadcasting\\InteractsWithSockets;
use Illuminate\\Broadcasting\\PrivateChannel;
use Illuminate\\Contracts\\Broadcasting\\ShouldBroadcast;
use Illuminate\\Foundation\\Events\\Dispatchable;
use Illuminate\\Queue\\SerializesModels;
class CustomerNotification implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $customerId,
public string $type,
public string $title,
public string $message,
public array $data = []
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('customer.' . $this->customerId),
];
}
public function broadcastAs(): string
{
return 'notification';
}
public function broadcastWith(): array
{
return [
'type' => $this->type,
'title' => $this->title,
'message' => $this->message,
'data' => $this->data,
'timestamp' => now()->toISOString(),
'id' => uniqid('notif_'),
];
}
}
Definisikan channel authorization di routes/channels.php.
<?php
// routes/channels.php
use App\\Models\\Customer;
use Illuminate\\Support\\Facades\\Broadcast;
Broadcast::channel('customer.{customerId}', function ($user, $customerId) {
// User hanya bisa subscribe ke channel miliknya sendiri
$customer = Customer::where('user_id', $user->id)->first();
return $customer && $customer->id === (int) $customerId;
});
// Channel untuk admin monitoring
Broadcast::channel('admin.notifications', function ($user) {
return $user->hasRole('admin');
});
// Presence channel untuk live chat support
Broadcast::channel('support.chat.{ticketId}', function ($user, $ticketId) {
return [
'id' => $user->id,
'name' => $user->name,
'role' => $user->hasRole('admin') ? 'agent' : 'customer',
];
});
Untuk production, kita bisa menggunakan Laravel Reverb sebagai WebSocket server. Install dan konfigurasi Reverb.
composer require laravel/reverb
php artisan reverb:install
Update .env untuk Reverb.
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=asuransiku
REVERB_APP_KEY=asuransiku-key
REVERB_APP_SECRET=asuransiku-secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
Konfigurasi Reverb di config/reverb.php.
<?php
// config/reverb.php
return [
'default' => env('REVERB_SERVER', 'reverb'),
'servers' => [
'reverb' => [
'host' => env('REVERB_HOST', '0.0.0.0'),
'port' => env('REVERB_PORT', 8080),
'hostname' => env('REVERB_HOST', 'localhost'),
'options' => [
'tls' => [],
],
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
],
],
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10000),
],
],
],
];
Jalankan Reverb server.
php artisan reverb:start
Untuk frontend, install Laravel Echo dan Pusher JS library.
npm install laravel-echo pusher-js
Konfigurasi Echo di resources/js/bootstrap.js.
// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
Buat component Vue atau React untuk menangani notifikasi. Berikut contoh dengan vanilla JavaScript.
// resources/js/notifications.js
class NotificationManager {
constructor(customerId) {
this.customerId = customerId;
this.notifications = [];
this.listeners = [];
this.initializeEcho();
}
initializeEcho() {
if (!window.Echo) {
console.error('Laravel Echo not initialized');
return;
}
// Subscribe ke private channel customer
window.Echo.private(`customer.${this.customerId}`)
.listen('.policy.status.updated', (event) => {
this.handlePolicyUpdate(event);
})
.listen('.claim.status.updated', (event) => {
this.handleClaimUpdate(event);
})
.listen('.notification', (event) => {
this.handleGenericNotification(event);
});
console.log(`Subscribed to customer.${this.customerId} channel`);
}
handlePolicyUpdate(event) {
console.log('Policy status updated:', event);
this.showToast({
type: event.new_status === 'active' ? 'success' : 'info',
title: 'Update Polis',
message: event.message,
icon: event.icon,
color: event.color,
});
this.addNotification({
id: `policy_${event.policy_id}_${Date.now()}`,
type: 'policy',
...event,
});
// Update UI elements
this.updatePolicyStatusUI(event.policy_id, event.new_status, event.status_label);
// Notify listeners
this.notifyListeners('policyUpdate', event);
}
handleClaimUpdate(event) {
console.log('Claim status updated:', event);
this.showToast({
type: event.new_status === 'approved' ? 'success' :
event.new_status === 'rejected' ? 'error' : 'info',
title: 'Update Klaim',
message: event.message,
icon: event.icon,
color: event.color,
});
this.addNotification({
id: `claim_${event.claim_id}_${Date.now()}`,
type: 'claim',
...event,
});
// Notify listeners
this.notifyListeners('claimUpdate', event);
}
handleGenericNotification(event) {
console.log('Notification received:', event);
this.showToast({
type: event.type,
title: event.title,
message: event.message,
});
this.addNotification(event);
// Notify listeners
this.notifyListeners('notification', event);
}
showToast(options) {
// Implement toast notification UI
// Bisa menggunakan library seperti toastr, sweetalert, atau custom
const toast = document.createElement('div');
toast.className = `toast toast-${options.type} animate-slide-in`;
toast.innerHTML = `
<div class="toast-icon">
<i class="icon-${options.icon || 'info'}"></i>
</div>
<div class="toast-content">
<div class="toast-title">${options.title}</div>
<div class="toast-message">${options.message}</div>
</div>
<button class="toast-close" onclick="this.parentElement.remove()">×</button>
`;
const container = document.getElementById('toast-container') || this.createToastContainer();
container.appendChild(toast);
// Auto remove after 5 seconds
setTimeout(() => toast.remove(), 5000);
}
createToastContainer() {
const container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed top-4 right-4 z-50 space-y-2';
document.body.appendChild(container);
return container;
}
addNotification(notification) {
this.notifications.unshift(notification);
// Keep only last 50 notifications
if (this.notifications.length > 50) {
this.notifications.pop();
}
// Update notification badge
this.updateNotificationBadge();
}
updateNotificationBadge() {
const unreadCount = this.notifications.filter(n => !n.read).length;
const badge = document.getElementById('notification-badge');
if (badge) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
badge.style.display = unreadCount > 0 ? 'block' : 'none';
}
}
updatePolicyStatusUI(policyId, status, statusLabel) {
const statusElement = document.querySelector(`[data-policy-id="${policyId}"] .policy-status`);
if (statusElement) {
statusElement.textContent = statusLabel;
statusElement.className = `policy-status status-${status}`;
}
}
addListener(callback) {
this.listeners.push(callback);
}
notifyListeners(eventType, data) {
this.listeners.forEach(callback => callback(eventType, data));
}
disconnect() {
if (window.Echo) {
window.Echo.leave(`customer.${this.customerId}`);
}
}
}
// Initialize when DOM ready
document.addEventListener('DOMContentLoaded', () => {
const customerIdElement = document.getElementById('customer-id');
if (customerIdElement) {
const customerId = customerIdElement.value;
window.notificationManager = new NotificationManager(customerId);
}
});
Buat service untuk mengirim notifikasi real-time dari backend.
<?php
// app/Services/RealtimeNotificationService.php
namespace App\\Services;
use App\\Events\\CustomerNotification;
use App\\Events\\PolicyStatusUpdated;
use App\\Events\\ClaimStatusUpdated;
use App\\Models\\Claim;
use App\\Models\\Customer;
use App\\Models\\Policy;
class RealtimeNotificationService
{
public function notifyPolicyStatusChange(
Policy $policy,
string $oldStatus,
string $newStatus,
string $message
): void {
PolicyStatusUpdated::dispatch($policy, $oldStatus, $newStatus, $message);
}
public function notifyClaimStatusChange(
Claim $claim,
string $oldStatus,
string $newStatus,
string $message
): void {
ClaimStatusUpdated::dispatch($claim, $oldStatus, $newStatus, $message);
}
public function sendNotification(
int $customerId,
string $type,
string $title,
string $message,
array $data = []
): void {
CustomerNotification::dispatch($customerId, $type, $title, $message, $data);
}
public function notifyPaymentReminder(Customer $customer, Policy $policy): void
{
$this->sendNotification(
$customer->id,
'warning',
'Pengingat Pembayaran',
"Premi polis {$policy->policy_number} akan jatuh tempo dalam 7 hari.",
[
'policy_id' => $policy->id,
'policy_number' => $policy->policy_number,
'due_date' => $policy->next_payment_date->format('d F Y'),
'amount' => $policy->monthly_premium,
]
);
}
public function notifyDocumentRequired(Customer $customer, Policy $policy, array $documents): void
{
$this->sendNotification(
$customer->id,
'info',
'Dokumen Diperlukan',
'Mohon lengkapi dokumen untuk melanjutkan proses pengajuan polis Anda.',
[
'policy_id' => $policy->id,
'policy_number' => $policy->policy_number,
'required_documents' => $documents,
]
);
}
}
Buat command untuk testing broadcast.
php artisan make:command TestBroadcast
<?php
// app/Console/Commands/TestBroadcast.php
namespace App\\Console\\Commands;
use App\\Events\\CustomerNotification;
use App\\Events\\PolicyStatusUpdated;
use App\\Models\\Customer;
use App\\Models\\Policy;
use Illuminate\\Console\\Command;
class TestBroadcast extends Command
{
protected $signature = 'broadcast:test {customer_id}';
protected $description = 'Test broadcasting to a customer channel';
public function handle(): int
{
$customerId = $this->argument('customer_id');
$customer = Customer::find($customerId);
if (!$customer) {
$this->error("Customer {$customerId} not found");
return Command::FAILURE;
}
$this->info("Broadcasting test notification to customer {$customer->name}...");
// Test generic notification
CustomerNotification::dispatch(
$customer->id,
'info',
'Test Notification',
'Ini adalah notifikasi test dari sistem AsuransiKu.',
['test' => true, 'timestamp' => now()->toISOString()]
);
$this->info("✓ Generic notification sent");
// Test policy status update jika ada policy
$policy = Policy::where('customer_id', $customer->id)->first();
if ($policy) {
PolicyStatusUpdated::dispatch(
$policy,
'pending',
'under_review',
'Pengajuan polis Anda sedang dalam proses review.'
);
$this->info("✓ Policy status notification sent for {$policy->policy_number}");
}
$this->newLine();
$this->info('Broadcast test completed! Check browser console for received events.');
return Command::SUCCESS;
}
}
Dengan implementasi real-time notification, nasabah AsuransiKu mendapatkan update instan tentang status pengajuan dan klaim mereka. Tidak perlu lagi refresh halaman berulang kali. Setiap perubahan status langsung terlihat di browser mereka dalam hitungan detik.
Di bagian terakhir, kita akan membahas best practices untuk Redis di production, monitoring, dan troubleshooting.
Bagian 10: Penutup - Best Practices, Monitoring, dan Langkah Selanjutnya
Implementasi Redis di aplikasi AsuransiKu sudah lengkap mencakup caching, session management, queue processing, rate limiting, dan real-time notifications. Untuk memastikan Redis berjalan optimal di production, kita perlu menerapkan best practices, monitoring yang tepat, dan strategi troubleshooting. Bagian terakhir ini merangkum semua yang sudah dipelajari dan memberikan panduan untuk pengembangan skill lebih lanjut.
Sepanjang tutorial ini, kita sudah mengubah website asuransi jiwa yang biasa menjadi aplikasi yang performant dan scalable. Mari kita recap apa saja yang sudah diimplementasikan.
Untuk caching data produk, kita menggunakan Cache-Aside pattern dengan TTL 24 jam. Response time untuk halaman produk turun dari 50-100ms menjadi kurang dari 5ms. Cache invalidation otomatis melalui Observer memastikan data selalu fresh ketika ada update.
Untuk caching tabel rate premi, kita menggunakan Redis Hash yang memungkinkan O(1) lookup. Kalkulator premi yang tadinya butuh 15-20ms per kalkulasi sekarang hanya butuh kurang dari 1ms. Nasabah bisa mencoba berbagai skenario coverage tanpa delay.
Untuk session management, Redis menggantikan file-based session yang tidak scalable. Aplikasi siap untuk horizontal scaling dengan multiple servers. Fitur logout from all devices memberikan kontrol keamanan lebih kepada nasabah.
Untuk queue processing, task berat seperti underwriting dan pengiriman dokumen diproses di background. Response time untuk submit aplikasi tetap cepat meskipun ada proses kompleks. Laravel Horizon memberikan visibility terhadap status queue.
Untuk rate limiting, API kalkulator dan endpoint publik terlindungi dari abuse. Berbagai tier limit untuk guest, authenticated user, dan partner API. Monitoring membantu mengidentifikasi pattern yang mencurigakan.
Untuk real-time notifications, nasabah mendapat update instan tentang status pengajuan dan klaim. Tidak perlu refresh halaman berulang kali. Experience yang modern dan responsif.
Sekarang mari kita bahas best practices untuk menjalankan Redis di production.
Memory management adalah aspek paling penting karena Redis menyimpan semua data di RAM. Konfigurasi maxmemory untuk membatasi penggunaan memory. Set eviction policy yang sesuai dengan use case.
# redis.conf
maxmemory 2gb
maxmemory-policy allkeys-lru
Policy allkeys-lru artinya ketika memory penuh, Redis akan menghapus key yang paling jarang diakses. Ini cocok untuk cache. Untuk session dan queue yang tidak boleh hilang sembarangan, pertimbangkan volatile-lru yang hanya menghapus key dengan expiration.
Untuk persistence, Redis menyediakan dua mekanisme yaitu RDB snapshots dan AOF (Append Only File). RDB membuat snapshot periodik yang bagus untuk backup. AOF mencatat setiap operasi write untuk durability maksimal.
# redis.conf
# RDB snapshots
save 900 1 # Snapshot jika ada 1 perubahan dalam 15 menit
save 300 10 # Snapshot jika ada 10 perubahan dalam 5 menit
save 60 10000 # Snapshot jika ada 10000 perubahan dalam 1 menit
# AOF
appendonly yes
appendfsync everysec # Sync ke disk setiap detik
Untuk security, jangan pernah expose Redis ke public internet. Gunakan firewall untuk membatasi akses hanya dari application server. Set password untuk authentication.
# redis.conf
bind 127.0.0.1 10.0.0.0/8 # Hanya localhost dan private network
requirepass your_strong_password_here
Update .env Laravel untuk menggunakan password.
REDIS_PASSWORD=your_strong_password_here
Untuk high availability, gunakan Redis Sentinel atau Redis Cluster. Sentinel memonitor master dan melakukan automatic failover ke replica jika master down. Cluster mendistribusikan data ke multiple nodes untuk horizontal scaling.
Buat command untuk monitoring kesehatan Redis.
php artisan make:command RedisHealthCheck
<?php
// app/Console/Commands/RedisHealthCheck.php
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Redis;
class RedisHealthCheck extends Command
{
protected $signature = 'redis:health';
protected $description = 'Check Redis health and display statistics';
public function handle(): int
{
$this->info('Redis Health Check');
$this->info('==================');
$this->newLine();
try {
// Test connection
$ping = Redis::ping();
$this->line("✓ Connection: <fg=green>OK</> (Response: {$ping})");
} catch (\\Exception $e) {
$this->line("✗ Connection: <fg=red>FAILED</> ({$e->getMessage()})");
return Command::FAILURE;
}
// Get Redis info
$info = Redis::info();
// Memory stats
$this->newLine();
$this->info('Memory Usage');
$this->line(" Used Memory: " . $this->formatBytes($info['used_memory']));
$this->line(" Used Memory Peak: " . $this->formatBytes($info['used_memory_peak']));
$this->line(" Used Memory RSS: " . $this->formatBytes($info['used_memory_rss']));
if (isset($info['maxmemory']) && $info['maxmemory'] > 0) {
$usagePercent = ($info['used_memory'] / $info['maxmemory']) * 100;
$status = $usagePercent > 80 ? '<fg=red>' : ($usagePercent > 60 ? '<fg=yellow>' : '<fg=green>');
$this->line(" Max Memory: " . $this->formatBytes($info['maxmemory']));
$this->line(" Usage: {$status}" . round($usagePercent, 1) . "%</>");
}
// Connection stats
$this->newLine();
$this->info('Connections');
$this->line(" Connected Clients: {$info['connected_clients']}");
$this->line(" Blocked Clients: {$info['blocked_clients']}");
$this->line(" Total Connections Received: {$info['total_connections_received']}");
// Performance stats
$this->newLine();
$this->info('Performance');
$this->line(" Total Commands Processed: " . number_format($info['total_commands_processed']));
$this->line(" Ops/sec: {$info['instantaneous_ops_per_sec']}");
$hitRate = 0;
if (isset($info['keyspace_hits']) && isset($info['keyspace_misses'])) {
$total = $info['keyspace_hits'] + $info['keyspace_misses'];
if ($total > 0) {
$hitRate = ($info['keyspace_hits'] / $total) * 100;
}
}
$hitStatus = $hitRate > 90 ? '<fg=green>' : ($hitRate > 70 ? '<fg=yellow>' : '<fg=red>');
$this->line(" Cache Hit Rate: {$hitStatus}" . round($hitRate, 1) . "%</>");
// Keyspace info
$this->newLine();
$this->info('Keyspace');
for ($db = 0; $db <= 3; $db++) {
$dbKey = "db{$db}";
if (isset($info[$dbKey])) {
$dbInfo = $info[$dbKey];
$this->line(" Database {$db}: {$dbInfo}");
}
}
// Persistence info
$this->newLine();
$this->info('Persistence');
$this->line(" RDB Last Save: " . date('Y-m-d H:i:s', $info['rdb_last_save_time']));
$this->line(" RDB Last Status: " . ($info['rdb_last_bgsave_status'] === 'ok' ? '<fg=green>OK</>' : '<fg=red>FAILED</>'));
if (isset($info['aof_enabled'])) {
$this->line(" AOF Enabled: " . ($info['aof_enabled'] ? 'Yes' : 'No'));
}
// Replication info
$this->newLine();
$this->info('Replication');
$this->line(" Role: {$info['role']}");
if ($info['role'] === 'master') {
$this->line(" Connected Slaves: {$info['connected_slaves']}");
}
$this->newLine();
$this->info('Health check completed!');
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return round($bytes, 2) . ' ' . $units[$i];
}
}
Schedule health check untuk berjalan periodik.
<?php
// routes/console.php
use Illuminate\\Support\\Facades\\Schedule;
Schedule::command('redis:health')->hourly()->emailOutputOnFailure('[email protected]');
Buat command untuk membersihkan cache yang tidak terpakai.
php artisan make:command RedisCleanup
<?php
// app/Console/Commands/RedisCleanup.php
namespace App\\Console\\Commands;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Redis;
class RedisCleanup extends Command
{
protected $signature = 'redis:cleanup {--dry-run}';
protected $description = 'Cleanup unused Redis keys and optimize memory';
public function handle(): int
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('Running in dry-run mode. No keys will be deleted.');
}
$this->info('Starting Redis cleanup...');
$this->newLine();
$totalCleaned = 0;
// Cleanup expired session tracking
$totalCleaned += $this->cleanupSessionTracking($dryRun);
// Cleanup old rate limit tracking
$totalCleaned += $this->cleanupRateLimitTracking($dryRun);
// Cleanup orphaned cache keys
$totalCleaned += $this->cleanupOrphanedCache($dryRun);
$this->newLine();
$this->info("Cleanup completed. Total keys " . ($dryRun ? 'would be ' : '') . "cleaned: {$totalCleaned}");
return Command::SUCCESS;
}
private function cleanupSessionTracking(bool $dryRun): int
{
$this->info('Cleaning up session tracking...');
$pattern = 'user_sessions:*';
$keys = Redis::keys($pattern);
$cleaned = 0;
foreach ($keys as $key) {
$sessionIds = Redis::smembers($key);
$validCount = 0;
foreach ($sessionIds as $sessionId) {
$sessionKey = config('cache.prefix', 'laravel_cache_') . 'session:' . $sessionId;
if (!Redis::exists($sessionKey)) {
if (!$dryRun) {
Redis::srem($key, $sessionId);
}
$cleaned++;
} else {
$validCount++;
}
}
// Remove empty tracking sets
if ($validCount === 0 && !$dryRun) {
Redis::del($key);
}
}
$this->line(" Session tracking entries cleaned: {$cleaned}");
return $cleaned;
}
private function cleanupRateLimitTracking(bool $dryRun): int
{
$this->info('Cleaning up rate limit tracking...');
$cleaned = 0;
$cutoffDate = date('Y-m-d', strtotime('-7 days'));
// Cleanup old daily tracking
for ($i = 7; $i < 30; $i++) {
$date = date('Y-m-d', strtotime("-{$i} days"));
$exceededKey = "rate_limit:exceeded:{$date}";
$highUsageKey = "rate_limit:high_usage:{$date}";
if (Redis::exists($exceededKey)) {
if (!$dryRun) {
Redis::del($exceededKey);
}
$cleaned++;
}
if (Redis::exists($highUsageKey)) {
if (!$dryRun) {
Redis::del($highUsageKey);
}
$cleaned++;
}
}
$this->line(" Rate limit tracking keys cleaned: {$cleaned}");
return $cleaned;
}
private function cleanupOrphanedCache(bool $dryRun): int
{
$this->info('Cleaning up orphaned cache...');
// This is a simplified example
// In production, be more careful about what to clean
$cleaned = 0;
// Example: cleanup coverage list cache older than 7 days
$pattern = '*coverage_list:*';
$keys = Redis::keys($pattern);
foreach ($keys as $key) {
$ttl = Redis::ttl($key);
// If TTL is -1 (no expiration) or very old, clean it
if ($ttl === -1) {
if (!$dryRun) {
Redis::expire($key, 86400); // Set 24 hour expiration
}
$cleaned++;
}
}
$this->line(" Orphaned cache keys cleaned: {$cleaned}");
return $cleaned;
}
}
Berikut adalah common issues dan cara troubleshooting.
Jika mengalami connection refused, pastikan Redis service berjalan dengan systemctl status redis-server. Cek apakah host dan port di .env sudah benar. Pastikan firewall tidak memblokir koneksi.
Jika mengalami out of memory, cek penggunaan memory dengan redis-cli INFO memory. Tingkatkan maxmemory jika server masih punya RAM tersedia. Review eviction policy apakah sudah sesuai. Cek apakah ada memory leak dari key yang tidak di-expire.
Jika cache miss rate tinggi, review TTL apakah terlalu pendek. Pastikan cache warming berjalan dengan benar. Cek apakah ada cache invalidation yang terlalu agresif.
Jika performance lambat, cek slow log dengan redis-cli SLOWLOG GET 10. Identifikasi command yang memakan waktu lama. Hindari command O(N) seperti KEYS di production, gunakan SCAN sebagai gantinya. Review apakah ada blocking operations.
# Melihat slow log
redis-cli SLOWLOG GET 10
# Monitor command real-time (hati-hati di production)
redis-cli MONITOR
# Melihat memory usage per key pattern
redis-cli --bigkeys
Untuk upgrade path, mulailah dengan single Redis instance untuk development dan production awal. Ketika traffic meningkat dan uptime menjadi critical, implementasikan Redis Sentinel dengan satu master dan dua replica untuk high availability. Jika data sudah sangat besar dan tidak muat di satu server, migrasi ke Redis Cluster untuk horizontal scaling.
Action plan untuk menerapkan Redis di project kalian. Minggu pertama, implementasikan caching untuk data yang sering diakses tapi jarang berubah. Ini memberikan quick win yang langsung terasa. Minggu kedua, migrasikan session ke Redis untuk persiapan scaling. Minggu ketiga, implementasikan queue untuk background processing. Minggu keempat, tambahkan rate limiting untuk security. Setelah itu, pertimbangkan real-time features jika sesuai dengan kebutuhan aplikasi.
Tingkatkan Skill Development Kalian di BuildWithAngga
Tutorial Redis ini adalah salah satu contoh bagaimana membangun aplikasi yang production-ready. Tapi masih banyak skill lain yang perlu dikuasai untuk menjadi developer profesional yang dibayar mahal.
Di BuildWithAngga, kami menyediakan kelas-kelas premium yang diajarkan langsung oleh mentor expert dengan pengalaman bertahun-tahun di industri. Setiap kelas dirancang dengan studi kasus nyata yang bisa langsung diterapkan di pekerjaan atau project freelance kalian.
Kelas Laravel Mastery: Build Enterprise Application mengajarkan cara membangun aplikasi skala enterprise dengan Laravel. Kalian akan belajar arsitektur yang scalable, design patterns seperti Repository dan Service Layer, testing yang comprehensive, CI/CD pipeline, dan deployment ke cloud. Studi kasusnya adalah membangun platform e-commerce B2B dengan fitur multi-tenant, payment gateway integration, dan reporting dashboard.
Kelas React & Next.js: Modern Frontend Development fokus pada frontend modern dengan React dan Next.js. Kalian akan membangun aplikasi dengan Server Side Rendering, Static Site Generation, dan Incremental Static Regeneration. Materi mencakup state management dengan Redux dan Zustand, styling dengan Tailwind CSS, dan integrasi dengan headless CMS. Cocok untuk yang ingin menjadi full-stack developer.
Kelas Flutter Mobile Development mengajarkan cara membangun aplikasi mobile cross-platform dengan Flutter. Dari dasar Dart programming sampai advanced topics seperti state management dengan BLoC, local storage, push notifications, dan publish ke App Store dan Play Store. Studi kasusnya adalah membangun aplikasi fintech dengan fitur transfer, pembayaran, dan investment portfolio.
Kelas DevOps & Cloud Infrastructure untuk developer yang ingin memahami sisi operations. Materi mencakup Docker containerization, Kubernetes orchestration, AWS dan Google Cloud services, infrastructure as code dengan Terraform, dan monitoring dengan Prometheus dan Grafana. Kalian akan belajar men-deploy aplikasi dengan zero downtime dan auto-scaling.
Kelas System Design & Architecture mengajarkan cara mendesain sistem yang bisa handle jutaan user. Kalian akan belajar tentang microservices, message queues, caching strategies, database sharding, CDN, dan load balancing. Materi ini penting untuk posisi senior engineer dan technical interview di perusahaan top.
Kelas UI/UX Design untuk Developer membantu developer memahami prinsip design yang baik. Kalian akan belajar design thinking, wireframing, prototyping dengan Figma, design system, dan accessibility. Dengan skill ini, kalian bisa membangun aplikasi yang tidak hanya fungsional tapi juga beautiful dan user-friendly.
Semua kelas di BuildWithAngga memberikan benefit yang tidak kalian dapatkan di tempat lain:
- Akses Selamanya - Sekali bayar, akses materi selamanya tanpa batas waktu. Termasuk semua update materi di masa depan.
- Project-Based Learning - Setiap kelas menghasilkan project nyata yang bisa langsung dimasukkan ke portfolio. Bukan tutorial todo app yang membosankan.
- Source Code Premium - Dapatkan starter code dan final code untuk setiap project. Hemat waktu setup dan bisa langsung fokus belajar konsep.
- Studi Kasus Industri - Project yang diajarkan adalah aplikasi yang benar-benar dibutuhkan industri. E-commerce, fintech, healthcare, dan enterprise systems.
- Mentor Expert - Belajar langsung dari praktisi yang sudah bertahun-tahun di industri. Bukan teori, tapi pengalaman nyata dari lapangan.
- Update Teknologi Terkini - Materi selalu di-update mengikuti perkembangan teknologi. Laravel 11, React 19, Flutter 3, dan teknologi terbaru lainnya.
- Komunitas Aktif - Gabung dengan ribuan developer Indonesia lainnya. Diskusi, networking, dan saling membantu dalam journey development.
- Konsultasi Karir - Dapatkan guidance untuk membangun karir sebagai developer. Review CV, tips interview, dan strategi negosiasi gaji.
- Sertifikat Profesional - Sertifikat completion yang bisa ditampilkan di LinkedIn dan CV untuk meningkatkan kredibilitas.
Investasi untuk belajar adalah investasi terbaik yang bisa kalian lakukan untuk karir. Developer yang terus belajar dan mengupdate skill adalah yang bertahan dan berkembang di industri ini.
Penutup
Redis adalah tool yang sangat powerful untuk meningkatkan performa aplikasi. Dengan memahami cara kerjanya dan best practices implementasinya, kalian bisa membangun aplikasi yang tidak hanya fungsional tapi juga cepat dan scalable.
Skill yang kita pelajari di tutorial ini, mulai dari caching, session management, queue processing, rate limiting, sampai real-time features, adalah skill yang sangat dicari di industri. Perusahaan membutuhkan developer yang bisa membangun aplikasi yang performant dan bisa di-scale.
Jangan berhenti di sini. Terus eksplorasi, terus belajar, dan terus membangun. Setiap project adalah kesempatan untuk mengasah skill dan menambah portfolio. Setiap error adalah kesempatan untuk belajar sesuatu yang baru.
Saya percaya setiap developer Indonesia punya potensi untuk menjadi world-class engineer. Yang dibutuhkan adalah kemauan untuk terus belajar, resource yang tepat, dan komunitas yang supportive. Itulah yang kami coba berikan di BuildWithAngga.
Terima kasih sudah mengikuti tutorial ini sampai selesai. Saya harap pengetahuan yang dibagikan bermanfaat untuk project dan karir kalian. Jika ada pertanyaan atau ingin diskusi lebih lanjut, jangan ragu untuk menghubungi kami.
Selamat coding dan terus berkembang!
Angga Risky SetiawanFounder, BuildWithAngga