Cache di Laravel — Strategi, Driver, dan Best Practice

Kalau blogmu lagi sepi, tidak ada yang perlu dioptimasi. Tapi begitu ramai — ratusan orang buka halaman yang sama dalam waktu bersamaan — tiba-tiba kamu sadar: setiap request menjalankan query yang sama, memproses data yang sama, dan menghasilkan output yang sama persis.

Itu pemborosan yang tidak perlu.

Cache adalah cara menyimpan hasil kerja yang sudah selesai, supaya tidak perlu dikerjakan ulang. Artikel ini akan bahas cara berpikir tentang cache, driver yang tersedia di Laravel, semua strategi caching yang relevan, dan jebakan-jebakan yang sering membuat pemula salah langkah.


🤔 Masalah yang Cache Selesaikan

Tanpa cache, setiap kali ada yang buka halaman artikel populer di blog kita, ini yang terjadi:

User buka /articles
  → Laravel terima request
  → Query ke database: ambil 10 artikel terbaru + jumlah komentar + data penulis
  → PHP proses hasil query
  → Render response
  → Kirim ke browser

Untuk 1 user: tidak masalah. Untuk 500 user dalam satu menit yang semua buka halaman yang sama: database kamu ketuk 500 kali untuk query yang hasilnya identik.

Cache memotong siklus itu:

Request pertama  → query DB → simpan hasil → kirim ke user
Request ke-2     → ambil dari cache → kirim ke user  ← tidak ada query DB
Request ke-3     → ambil dari cache → kirim ke user  ← tidak ada query DB
...sampai cache kedaluwarsa...
Request ke-501   → query DB lagi → perbarui cache → kirim ke user

Kuncinya ada di pertanyaan ini: seberapa sering data ini berubah, dan seberapa mahal biaya mengambilnya? Kalau data jarang berubah tapi mahal diambil — itu kandidat cache yang bagus.


🗄️ Driver — “Tempat Menyimpan Cache”

Laravel mendukung beberapa driver cache. Pilihan driver menentukan di mana data cache disimpan secara fisik.

File — Default, Tanpa Setup

# .env
CACHE_STORE=file

Cache disimpan sebagai file di storage/framework/cache/. Tidak butuh software tambahan — langsung jalan di instalasi Laravel baru.

// Ini langsung jalan tanpa konfigurasi apapun
Cache::put('articles.featured', $articles, now()->addHour());
$articles = Cache::get('articles.featured');

Cocok untuk: development lokal, aplikasi kecil dengan traffic rendah, atau ketika belum ada infrastruktur Redis/Memcached.

Tidak cocok untuk: aplikasi yang jalan di beberapa server sekaligus (file cache tidak di-share antar server), atau traffic tinggi (banyak file kecil = banyak disk I/O).

Redis — Pilihan Produksi

# .env
CACHE_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

Redis menyimpan data di memori — jauh lebih cepat dari disk. Selain itu, Redis mendukung banyak tipe data dan punya fitur seperti pub/sub dan atomic operations yang berguna untuk cache lock.

# Install driver PHP untuk Redis
composer require predis/predis
# atau pakai ekstensi PHP: pecl install redis

Cocok untuk: hampir semua aplikasi produksi. Terutama ketika aplikasi butuh session, cache, dan queue di infrastruktur yang sama.

Memcached — Alternatif Redis

# .env
CACHE_STORE=memcached
MEMCACHED_HOST=127.0.0.1

Mirip Redis — berbasis memori, sangat cepat. Lebih sederhana dari Redis karena hanya fokus pada caching. Pilih Redis kalau sudah ada di infrastrukturmu, pilih Memcached kalau tim sudah familiar dengannya.

Database — Cache di Tabel SQL

# .env
CACHE_STORE=database

Cache disimpan di tabel cache di database utama. Butuh satu langkah setup:

php artisan cache:table   # generate migration
php artisan migrate

Cocok untuk: ketika tidak ada Redis/Memcached tapi butuh cache yang di-share antar server. Lebih lambat dari Redis karena masih query SQL, tapi lebih baik dari file untuk multi-server setup.

Array — Untuk Testing

Cache hanya hidup di memori PHP selama satu request. Tidak persistent, tidak menyentuh Redis atau file sungguhan — sempurna untuk unit test.

# .env.testing
CACHE_STORE=array

⚙️ Konfigurasi Dasar

📁 config/cache.php

<?php

return [
    // Driver default yang dipakai kalau tidak disebutkan eksplisit
    'default' => env('CACHE_STORE', 'file'),

    'stores' => [
        'file' => [
            'driver' => 'file',
            'path'   => storage_path('framework/cache/data'),
        ],

        'redis' => [
            'driver'          => 'redis',
            'connection'      => 'cache',  // → lihat config/database.php bagian redis
            'lock_connection' => 'default',
        ],

        'database' => [
            'driver'     => 'database',
            'table'      => 'cache',
            'connection' => null,
        ],
    ],

    // Prefix untuk semua key — berguna kalau Redis dipakai bersama aplikasi lain
    'prefix' => env('CACHE_PREFIX', 'blog_cache'),
];

📌 Satu aplikasi bisa pakai lebih dari satu driver sekaligus — misalnya Redis untuk data yang sering diakses, dan database untuk data yang perlu persist lebih lama. Kamu tinggal sebut driver mana yang mau dipakai saat memanggil cache.


🔧 Operasi Dasar

Sebelum masuk ke strategi, kenali semua operasi dasar yang tersedia:

use Illuminate\Support\Facades\Cache;

// Simpan dengan TTL (time-to-live) — kedaluwarsa setelah 1 jam
Cache::put('articles.featured', $articles, now()->addHour());
Cache::put('articles.featured', $articles, 3600);          // sama, dalam detik

// Simpan selamanya — tidak kedaluwarsa kecuali dihapus manual
Cache::forever('site.settings', $settings);

// Ambil — null kalau tidak ada
$articles = Cache::get('articles.featured');

// Ambil dengan nilai default kalau tidak ada
$articles = Cache::get('articles.featured', []);
$articles = Cache::get('articles.featured', fn() => Article::latest()->get());

// Cek keberadaan
if (Cache::has('articles.featured')) { ... }

// Hapus satu key
Cache::forget('articles.featured');

// Hapus semua cache — hati-hati di produksi
Cache::flush();

🧠 Strategi — Cara Berpikir tentang Cache

Ini bagian terpenting. Punya semua operasi di atas tidak cukup — kamu perlu tahu pola pikir yang tepat untuk tiap situasi.

Strategi 1: Cache-Aside (Lazy Loading) — “Simpan Saat Dibutuhkan”

Ini strategi paling umum dan paling aman untuk pemula. Prinsipnya: cek cache dulu, kalau tidak ada baru ambil dari DB dan simpan.

Laravel menyediakan remember() yang langsung menggabungkan ketiga langkah itu:

// Tanpa remember() — harus tulis sendiri tiga langkah
$articles = Cache::get('articles.published');
if (!$articles) {
    $articles = Article::with('user')
        ->where('status', 'published')
        ->latest('published_at')
        ->get();
    Cache::put('articles.published', $articles, now()->addMinutes(30));
}

// Dengan remember() — satu baris, logika sama persis
$articles = Cache::remember('articles.published', now()->addMinutes(30), function () {
    return Article::with('user')
        ->where('status', 'published')
        ->latest('published_at')
        ->get();
});

📌 remember() adalah jantung dari caching di Laravel. Kalau key ada di cache, closure tidak dijalankan sama sekali — query tidak terjadi. Kalau tidak ada, closure dijalankan, hasilnya disimpan, dan dikembalikan.

Studi kasus lengkap di ArticleService:

📁 app/Services/ArticleService.php

<?php

namespace App\Services;

use App\Models\Article;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;

class ArticleService
{
    public function getPublished(): Collection
    {
        // Key yang deskriptif dan konsisten — mudah dilacak dan di-invalidate
        return Cache::remember('articles.published', now()->addMinutes(30), function () {
            return Article::with('user')
                ->where('status', 'published')
                ->latest('published_at')
                ->get();
        });
    }

    public function findWithComments(int $id): Article
    {
        // Key per-record — tiap artikel punya cache sendiri
        return Cache::remember("article.{$id}.with_comments", now()->addHour(), function () use ($id) {
            return Article::with(['user', 'comments.user'])
                ->findOrFail($id);
        });
    }

    public function getPopular(int $limit = 5): Collection
    {
        // Data yang jarang berubah bisa cache lebih lama
        return Cache::remember("articles.popular.{$limit}", now()->addDay(), function () use ($limit) {
            return Article::withCount('comments')
                ->where('status', 'published')
                ->orderByDesc('comments_count')
                ->limit($limit)
                ->get();
        });
    }
}

Strategi 2: Cache Invalidation — “Hapus Cache Saat Data Berubah”

Menyimpan cache mudah. Yang susah adalah memastikan cache tidak basi — menampilkan data lama padahal data aslinya sudah berubah.

Cara paling andal: hapus cache yang relevan setiap kali data berubah.

📁 app/Services/ArticleService.php (lanjutan)

public function create(array $data, User $author): Article
{
    $article = Article::create([
        'user_id'      => $author->id,
        'title'        => $data['title'],
        'body'         => $data['body'],
        'status'       => $data['status'],
        'published_at' => $data['status'] === 'published' ? now() : null,
    ]);

    // Artikel baru dibuat → cache daftar artikel sudah tidak valid
    $this->clearArticleListCache();

    return $article;
}

public function update(Article $article, array $data): Article
{
    $article->update($data);

    // Data artikel berubah → cache artikel ini dan cache daftar tidak valid lagi
    Cache::forget("article.{$article->id}.with_comments");
    $this->clearArticleListCache();

    return $article->fresh();
}

public function delete(Article $article): void
{
    $article->delete();

    Cache::forget("article.{$article->id}.with_comments");
    $this->clearArticleListCache();
}

private function clearArticleListCache(): void
{
    Cache::forget('articles.published');
    Cache::forget('articles.popular.5');
    Cache::forget('articles.popular.10');
}

📌 Ini disebut manual invalidation — kamu yang bertanggung jawab menghapus cache saat data berubah. Pendekatan ini eksplisit dan mudah dipahami, tapi butuh disiplin: setiap operasi tulis harus diikuti dengan membersihkan cache yang relevan.

Strategi 3: Cache Tags — “Hapus Sekelompok Sekaligus”

Masalah manual invalidation: kamu harus hafal semua key yang perlu dihapus. Kalau key-nya banyak dan tersebar, mudah kelewatan satu.

Cache tags memungkinkan kamu memberi label pada sekelompok cache, lalu menghapus semuanya dengan satu perintah.

// Simpan dengan tag
Cache::tags(['articles', 'published'])->remember(
    'articles.published',
    now()->addMinutes(30),
    fn() => Article::published()->with('user')->get()
);

Cache::tags(['articles'])->remember(
    "article.{$id}.detail",
    now()->addHour(),
    fn() => Article::with(['user', 'comments'])->findOrFail($id)
);

Cache::tags(['articles', 'popular'])->remember(
    'articles.popular',
    now()->addDay(),
    fn() => Article::popular()->limit(5)->get()
);
// Kalau artikel dibuat/diupdate/dihapus — satu baris bersihkan semua
public function create(array $data, User $author): Article
{
    $article = Article::create([...]);

    Cache::tags(['articles'])->flush();  // hapus semua cache bertag 'articles'

    return $article;
}

📌 Cache tags hanya tersedia untuk driver Redis dan Memcached — tidak bisa dipakai dengan file atau database driver. Kalau kamu pakai file driver di development tapi Redis di production, pastikan test cache invalidation-mu dilakukan di environment yang sama dengan production.

Strategi 4: rememberForever — Cache yang Dikontrol Manual

Untuk data yang hampir tidak pernah berubah — pengaturan situs, daftar kategori, statistik bulanan — cache selamanya dan hapus manual hanya saat data benar-benar berubah.

📁 app/Services/SettingService.php

<?php

namespace App\Services;

use App\Models\Setting;
use Illuminate\Support\Facades\Cache;

class SettingService
{
    public function getAll(): array
    {
        // Simpan selamanya — tidak ada TTL
        return Cache::rememberForever('site.settings', function () {
            return Setting::all()->pluck('value', 'key')->toArray();
        });
    }

    public function update(string $key, string $value): void
    {
        Setting::updateOrCreate(['key' => $key], ['value' => $value]);

        // Hapus cache — akan di-rebuild otomatis saat diakses berikutnya
        Cache::forget('site.settings');
    }
}

Strategi 5: Cache Lock — “Cegah Stampede”

Ada satu masalah yang sering diabaikan: cache stampede (disebut juga thundering herd).

Bayangkan cache artikel populer kedaluwarsa persis ketika 200 user mengaksesnya bersamaan. Sebelum cache sempat diisi ulang, ke-200 request itu masing-masing langsung query ke database — database mendapat 200 query identik dalam satu detik.

Cache lock mencegah ini: hanya satu proses yang boleh rebuild cache, sisanya menunggu.

public function getPublishedArticles(): Collection
{
    $cacheKey = 'articles.published';

    // Coba ambil dari cache dulu
    if ($cached = Cache::get($cacheKey)) {
        return $cached;
    }

    // Minta lock selama 10 detik — hanya satu proses yang bisa masuk
    return Cache::lock('lock.' . $cacheKey, 10)->block(5, function () use ($cacheKey) {
        // Double-check: mungkin proses lain sudah isi cache saat kita menunggu
        if ($cached = Cache::get($cacheKey)) {
            return $cached;  // ← dapat dari cache, tidak perlu query lagi
        }

        // Baru sekarang query ke DB
        $articles = Article::with('user')
            ->where('status', 'published')
            ->latest('published_at')
            ->get();

        Cache::put($cacheKey, $articles, now()->addMinutes(30));

        return $articles;
    });
}

📌 block(5, ...) artinya: tunggu maksimal 5 detik untuk dapat lock. Kalau tidak dapat dalam 5 detik, lempar LockTimeoutException. Double-check setelah dapat lock penting — kalau proses lain sudah rebuild cache saat kita menunggu, tidak perlu query lagi.


🗝️ Penamaan Key yang Baik

Key yang buruk adalah sumber bug cache yang paling sering terjadi. Beberapa aturan yang membuat hidup lebih mudah:

// ✅ Pola hierarkis dengan titik sebagai pemisah
'articles.published'
'articles.popular.5'
"article.{$id}.detail"
"article.{$id}.with_comments"
"user.{$userId}.articles"
"user.{$userId}.comment_count"

// ✅ Sertakan semua parameter yang mempengaruhi hasil
"articles.published.page.{$page}"
"articles.tag.{$tagSlug}"
"search.articles.{$query}.page.{$page}"

// ❌ Terlalu generik — sulit di-invalidate dengan tepat
'data'
'results'
'articles'   // halaman berapa? filter apa?

// ❌ Tanpa parameter pembeda — berbagi cache padahal hasilnya beda
'user_articles'    // user mana?
'popular'          // limit berapa?

📌 Bayangkan key cache seperti nama variabel yang harus bisa menjelaskan isinya tanpa perlu membaca kodenya. Sertakan semua variabel yang mempengaruhi hasil sebagai bagian dari key.


🧪 Cache di Testing

Untuk unit test, pakai driver array dan manfaatkan Cache::fake():

// Di test — fake Cache untuk assertion
public function test_article_list_is_cached(): void
{
    Cache::shouldReceive('remember')
        ->once()
        ->with('articles.published', \Mockery::any(), \Mockery::any())
        ->andReturn(collect([]));

    $this->getJson('/api/articles')->assertOk();
}

// Pakai Cache::fake() untuk tes invalidation
public function test_cache_cleared_after_article_created(): void
{
    Cache::fake();

    $this->postJson('/api/articles', [
        'title'  => 'Artikel Baru',
        'body'   => str_repeat('a', 100),
        'status' => 'published',
    ]);

    // Assert cache yang seharusnya dihapus memang terhapus
    Cache::assertForgotten('articles.published');
}

🗺️ Gambaran Besar — Strategi per Tipe Data

Tipe Data                    Strategi          TTL           Notes
──────────────────────────────────────────────────────────────────────
Daftar artikel published     remember()        30 menit      Invalidate saat artikel CRUD
Detail artikel + komentar    remember()        1 jam         Invalidate saat artikel/komentar CRUD
Artikel populer              remember()        1 hari        Invalidate saat artikel CRUD
Pengaturan situs             rememberForever   Manual        Invalidate saat setting diupdate
Hasil pencarian              remember()        10 menit      Key sertakan query string
Statistik bulanan            rememberForever   Manual        Rebuild tiap awal bulan

Dan alur invalidation lengkapnya:

Article dibuat / diupdate / dihapus
  → Cache::tags(['articles'])->flush()   ← kalau pakai tags
  → atau clearArticleListCache()         ← kalau manual

Comment disetujui / dihapus
  → Cache::forget("article.{$id}.with_comments")

Setting diupdate
  → Cache::forget('site.settings')

🧭 Ringkasan Strategi

StrategiMethodCocok untuk
Cache-Asideremember()Data yang sering dibaca, sesekali berubah
ForeverrememberForever() + forget()Data yang hampir tidak pernah berubah
Tag-basedtags()->flush()Banyak key terkait yang perlu dihapus bersama
Cache Locklock()->block()Data traffic tinggi, cegah stampede

💡 Ingat!

Ada dua kesalahan paling umum dalam caching yang perlu kamu hindari.

Pertama: terlalu banyak cache. Tidak semua data perlu di-cache. Data yang berubah sangat sering justru membuat cache menjadi beban karena terlalu sering di-invalidate. Cache paling efektif untuk data yang read-heavy tapi write-light.

Kedua: lupa invalidate. Cache yang tidak di-invalidate dengan benar menampilkan data basi — dan ini lebih berbahaya dari tidak ada cache sama sekali, karena kamu berpikir data sudah benar padahal tidak. Setiap kali kamu tambah operasi tulis, tanya: “cache mana yang perlu dihapus setelah ini?”

Kalau kamu menjaga dua hal itu, cache akan menjadi salah satu optimasi dengan dampak terbesar yang bisa kamu tambahkan ke aplikasi Laravel.

Share Twitter/X LinkedIn