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:

AspekSessionCache
CakupanPer pengguna (per session ID)Global, dibagi semua pengguna
Isi khasKeranjang, status login, temaHasil query, response API, fragment view
Hubungan dengan data sumberSumber kebenaran itu sendiriHanya salinan; sumber tetap database
Konsekuensi hilangPengguna login ulang, keranjang kosongTerjadi cache miss, data dihitung ulang
Berakhir karenaLogout, kedaluwarsa idleTTL (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:

php
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:

text
remember: miss=jumlah-post: 3 hit=jumlah-post: 3 panggilan_closure=1

Dua 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():

php
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:

text
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:

php
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:

text
query_log: panggilan_pertama=1_query panggilan_kedua=0_query a=3 b=3

Panggilan 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:

php
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:

text
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:

php
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:

text
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 .