Sepanjang section ini kita memakai session sebagai memori per pengguna: keranjang Budi terpisah dari keranjang Siti. Ada jenis memori lain yang karakternya berbeda: cache. Cache tidak peduli siapa penggunanya — cache menyimpan hasil komputasi agar pekerjaan mahal tidak diulang-ulang, baik agregasi database yang berat, panggilan API pihak ketiga, maupun potongan HTML yang sudah dirender. Pada chapter ini kita pahami kapan cache memberi keuntungan nyata, bagaimana siklus hit-miss-expiration bekerja, dan bagaimana Laravel menyajikannya lewat Cache facade.
Cache vs Session: Dua Memori yang Berbeda #
Keduanya sama-sama “menyimpan sesuatu di server”, tetapi kegunaannya tidak saling menggantikan:
| Aspek | Session | Cache |
|---|---|---|
| Cakupan | Per pengguna (per session ID) | Global, dibagi semua pengguna |
| Isi khas | Keranjang, status login, tema | Hasil query, response API, fragment view |
| Hubungan dengan data sumber | Sumber kebenaran itu sendiri | Hanya salinan; sumber tetap database |
| Konsekuensi hilang | Pengguna login ulang, keranjang kosong | Terjadi cache miss, data dihitung ulang |
| Berakhir karena | Logout, kedaluwarsa idle | TTL (time-to-live) atau invalidasi |
Satu baris terakhir tabel itu adalah perbedaan filosofisnya: data session adalah datanya — jika hilang, hilanglah keranjang Budi. Data cache hanyalah salinan — jika hilang, aplikasi cukup menghitung ulang. Karena itu kehilangan cache tidak pernah membuat data rusak, hanya membuat request berikutnya lebih lambat.
Hit, Miss, dan Expiration #
Siklus hidup cache berputar pada tiga peristiwa. Cache hit: kunci ditemukan, hasil langsung dipakai. Cache miss: kunci tidak ada atau kedaluwarsa, komputasi dijalankan lalu hasilnya disimpan. Expiration: setiap entri punya masa tinggal (TTL) agar data basi dibuang otomatis.
Method remember() merangkai ketiganya dalam satu panggilan — ambil dari cache, jika kosong jalankan closure lalu simpan. Feature test berikut menghitung berapa kali closure benar-benar dieksekusi:
public function test_hit_miss_dengan_counter(): void
{
$panggilan = 0;
$hitung = function () use (&$panggilan) {
$panggilan++;
return 'jumlah-post: 3';
};
$miss = Cache::remember('stats:jumlah-post', 300, $hitung);
$hit = Cache::remember('stats:jumlah-post', 300, $hitung);
fwrite(STDERR, "remember: miss={$miss} hit={$hit} panggilan_closure={$panggilan}\n");
}Output yang Dihasilkan:
remember: miss=jumlah-post: 3 hit=jumlah-post: 3 panggilan_closure=1Dua panggilan remember() menghasilkan nilai yang sama, tetapi closure hanya dieksekusi sekali — panggilan kedua dilayani dari cache. Angka 300 adalah TTL dalam detik: setelah lima menit, entri kedaluwarsa dan closure akan dieksekusi lagi. Masa hidup itu bisa kita buktikan langsung dengan membekukan waktu lewat Carbon::setTestNow():
public function test_masa_hidup_cache(): void
{
Cache::put('kota', 'Bandung', 10);
fwrite(STDERR, 'sebelum_expired: kota=' . var_export(Cache::get('kota'), true) . "\n");
Carbon::setTestNow(Carbon::now()->addSeconds(11));
fwrite(STDERR, 'setelah_11_detik: kota=' . var_export(Cache::get('kota'), true) . "\n");
Carbon::setTestNow();
}Output yang Dihasilkan:
sebelum_expired: kota='Bandung'
setelah_11_detik: kota=NULL (10 detik = kedaluwarsa)Entri berumur 10 detik masih hidup saat ditanya, lalu menghilang setelah waktu maju 11 detik. setTestNow() dipakai agar test tidak perlu tidur sungguhan 11 detik — jam dimajukan, bukan ditunggu.
Target yang Layak Di-cache #
Kandidat terbaik adalah komputasi yang mahal dan relatif jarang berubah: agregasi database (jumlah user, total pendapatan bulan ini), response API pihak ketiga yang dibatasi rate limit, dan fragment view yang berat. Buktinya bisa kita ukur dengan query log. Feature test berikut meng-cache User::count() lalu menghitung query yang benar-benar dijalankan:
public function test_cache_menghemat_query(): void
{
User::factory()->count(3)->create();
Cache::forget('stats:jumlah-user');
DB::enableQueryLog();
$a = Cache::remember('stats:jumlah-user', 300, fn () => User::count());
$log1 = count(DB::getQueryLog());
DB::flushQueryLog();
$b = Cache::remember('stats:jumlah-user', 300, fn () => User::count());
$log2 = count(DB::getQueryLog());
DB::disableQueryLog();
fwrite(STDERR, "query_log: panggilan_pertama={$log1}_query panggilan_kedua={$log2}_query a={$a} b={$b}\n");
}Output yang Dihasilkan:
query_log: panggilan_pertama=1_query panggilan_kedua=0_query a=3 b=3Panggilan pertama menjalankan satu query count ke database; panggilan kedua menjalankan nol query karena hasilnya sudah tersimpan. Pada dashboard yang menampilkan sepuluh widget agregasi dan dikunjungi ribuan kali per jam, penghematan seperti ini berlipat menjadi puluhan ribu query yang tidak jadi dieksekusi.
Cache Facade di Balik Layar #
Seluruh operasi di atas kita akses lewat Cache facade. Berikut rangkaian lengkap API dasarnya dalam satu test:
public function test_api_dasar_cache(): void
{
Cache::put('kota', 'Bandung', 300);
fwrite(STDERR, 'put_get: kota=' . Cache::get('kota') . "\n");
fwrite(STDERR, 'has: ' . var_export(Cache::has('kota'), true) . "\n");
fwrite(STDERR, 'add (sudah ada): ' . var_export(Cache::add('kota', 'Jakarta', 300), true)
. ' nilai_tetap=' . Cache::get('kota') . "\n");
Cache::forget('kota');
fwrite(STDERR, 'setelah_forget: kota=' . var_export(Cache::get('kota'), true) . "\n");
fwrite(STDERR, 'default: kota=' . var_export(Cache::get('kota', 'tidak ada'), true) . "\n");
}Output yang Dihasilkan:
put_get: kota=Bandung
has: true
add (sudah ada): false nilai_tetap=Bandung
setelah_forget: kota=NULL
default: kota='tidak ada'Method add() adalah put() yang sopan: hanya menulis bila kunci belum ada, seperti terlihat dari false yang dikembalikan saat kunci kota masih terisi. Method get() menerima nilai default sebagai argumen kedua untuk kasus miss.
Persis seperti session, cache juga punya driver yang menentukan lokasi penyimpanan — .env kita memakai CACHE_STORE=database, sehingga entri tinggal di tabel cache yang dibuat migrasi bawaan. Feature test yang menyetel driver database dan membekukan waktu memperlihatkan barisnya secara nyata:
Carbon::setTestNow('2026-01-01 10:00:00');
config(['cache.default' => 'database']);
Cache::put('kota', 'Bandung', 300);
$row = DB::table('cache')->where('key', 'like', '%kota')->first();
fwrite(STDERR, 'tabel_cache: key=' . $row->key . ' value=' . $row->value
. ' expiration=' . $row->expiration . "\n");Output yang Dihasilkan:
tabel_cache: key=laravel-cache-kota value=s:7:"Bandung"; expiration=1767261900 (2026-01-01 10:05:00)Kunci kota tersimpan dengan prefix laravel-cache-, nilainya ter-serialize, dan kolom expiration berisi timestamp lima menit setelah penyimpanan — TTL 300 detik yang kita minta, tercatat hitam di atas putih.
Kapan Cache Justru Merugikan #
Cache bukan bumbu yang dicicip ke semua masakan. Ada kondisi yang membuatnya bumerang. Data yang berubah setiap detik — saldo akun, posisi pesanan terbaru — akan terus-menerus miss atau lebih buruk: menampilkan angka basi. Aplikasi kecil yang query-nya cepat sudah cukup responsif; menambah cache berarti menambah satu masalah baru (konsistensi, debugging “kok datanya tidak berubah?”) untuk menghemat waktu yang tidak signifikan. Dan pada trafik tinggi, cache stampede mengintai: saat entri populer kedaluwarsa bersamaan, ratusan request miss sekaligus dan menghantam database secara serempak — Laravel menyediakan atomic lock untuk mengatasinya, bahasan chapter cache lanjutan.
Konsep di tangan, saatnya mengoperasikannya secara penuh: memilih cache driver, mengatur TTL dengan objek Carbon, men-tag entri, dan menghapusnya secara selektif — semua itu materi chapter Cache Drivers
. Rujukan API lengkapnya tersedia di dokumentasi Cache
.