TTL yang kita atur di chapter sebelumnya membatasi umur cache, tetapi tidak tahu kapan data sumber benar-benar berubah. Dengan TTL lima menit, tulisan yang baru diedit masih tampil versi lamanya sampai lima menit berlalu — bagi editor yang ingin koreksinya segera terlihat, itu terlalu lama. Invalidasi aktif menutup celah ini: begitu data berubah, cache terkait langsung dibuang atau disegarkan pada saat yang sama. Pada chapter penutup section ini kita rangkai semuanya: dari pemicu invalidasi berbasis event model, kunci berversi, sampai pilihan pola cache-aside versus write-through.

Masalah Data Basi #

Mari kita lihat dulu betapa mudahnya data basi muncul. Feature test berikut meng-cache judul satu post, lalu mengubah judulnya lewat query builder:

php
$post = Post::create(['title' => 'Belajar Laravel']);
Cache::remember("posts:detail:{$post->id}", 300, fn () => $post->title);

// Update lewat query builder: event Eloquent tidak terpicu
DB::table('posts')->where('id', $post->id)->update(['title' => 'Judul Direvisi']);

$dariCache = Cache::get("posts:detail:{$post->id}");
$dariDb = DB::table('posts')->where('id', $post->id)->value('title');
fwrite(STDERR, "stale: cache={$dariCache} db={$dariDb}\n");

Output yang Dihasilkan:

text
stale: cache=Belajar Laravel db=Judul Direvisi

Database sudah menyimpan Judul Direvisi, tetapi cache masih memegang Belajar Laravel dan akan terus memegangnya sampai TTL 300 detik habis. Pesannya tegas: invalidasi tidak terjadi dengan sendirinya — ia harus dipicu oleh kode kita, dan pemicunya hanya sekuat jalur data yang kita pantau.

Observer Model sebagai Pemicu #

Perubahan yang melewati Eloquent memicu event saved dan deleted — momen sempurna untuk membuang cache yang kini usang. Observer menangkap event tersebut, dan attribute ObservedBy mendaftarkannya langsung pada model:

php
// app/Observers/PostObserver.php
namespace App\Observers;

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

class PostObserver
{
    public function saved(Post $post): void
    {
        Cache::forget('posts:semua');
        Cache::forget('stats:jumlah-post');
    }

    public function deleted(Post $post): void
    {
        Cache::forget('posts:semua');
        Cache::forget('stats:jumlah-post');
    }
}
php
// app/Models/Post.php
namespace App\Models;

use App\Observers\PostObserver;
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
use Illuminate\Database\Eloquent\Model;

#[ObservedBy(PostObserver::class)]
class Post extends Model
{
    protected $fillable = ['title'];
}

Event saved terpilih karena menutupi created sekaligus updated — dua perubahan yang sama-sama membuat cache daftar usang. Feature test berikut memutar siklusnya penuh:

php
Post::create(['title' => 'Belajar Laravel']);
Post::create(['title' => 'Tulisan Pertama']);

$panggilan = 0;
$ambilDaftar = function () use (&$panggilan) {
    $panggilan++;

    return Post::orderBy('title')->pluck('title')->all();
};

$sebelum = Cache::remember('posts:semua', 300, $ambilDaftar);
Cache::remember('posts:semua', 300, $ambilDaftar);
$panggilanSebelumUbah = $panggilan;

Post::create(['title' => 'Tulisan Baru']); // observer memicu forget()
$cacheSetelahSimpan = Cache::get('posts:semua');
$sesudah = Cache::remember('posts:semua', 300, $ambilDaftar);

fwrite(STDERR, 'observer: daftar_awal=' . json_encode($sebelum)
    . " panggilan_closure={$panggilan}"
    . " ({$panggilanSebelumUbah} sebelum simpan)\n");
fwrite(STDERR, 'observer: cache_setelah_simpan='
    . var_export($cacheSetelahSimpan, true)
    . ' daftar_baru=' . json_encode($sesudah) . "\n");

Output yang Dihasilkan:

text
observer: daftar_awal=["Belajar Laravel","Tulisan Pertama"] panggilan_closure=2 (1 sebelum simpan)
observer: cache_setelah_simpan=NULL daftar_baru=["Belajar Laravel","Tulisan Baru","Tulisan Pertama"]

Alurnya terbaca dari angka-angka itu: closure berjalan sekali, panggilan kedua dilayani cache, lalu Post::create() mengosongkan posts:semua (cache_setelah_simpan=NULL), dan panggilan remember() berikutnya menghitung ulang sampai datangnya Tulisan Baru. Penghapusan juga tertangkap — cache_sebelum_delete=true, cache_sesudah_delete=false — dan data turunan ikut aman: counter: awal=1 cache_setelah_create=NULL dihitung_ulang=2.

Kunci Berversi #

Cara invalidasi lain yang tidak menghapus apa pun: naikkan versi di dalam nama kunci. Semua pembaca memakai nama baru, dan entri lama dibiarkan mati sendiri saat TTL habis:

php
Cache::flush();
$versi = 1;
Cache::remember("posts:v{$versi}:semua", 300, fn () => ['Belajar Laravel']);
$isiV1 = Cache::get("posts:v1:semua");

// Data berubah: naikkan versi alih-alih menghapus satu-satu
$versi = 2;
$isiV2 = Cache::get("posts:v2:semua");
Cache::remember("posts:v{$versi}:semua", 300,
    fn () => ['Belajar Laravel', 'Tulisan Pertama']);

echo 'v1=' . $isiV1[0]
    . ' v2_sebelum_ada=' . var_export($isiV2 === null, true)
    . ' v2_sesudah=' . json_encode(Cache::get("posts:v2:semua"))
    . ' entri_v1_tetap_ada=' . var_export(Cache::has("posts:v1:semua"), true) . "\n";

Output yang Dihasilkan:

text
v1=Belajar Laravel v2_sebelum_ada=true v2_sesudah=["Belajar Laravel","Tulisan Pertama"] entri_v1_tetap_ada=true (menunggu TTL)

Pola ini bersinar saat jumlah kunci sangat banyak: “invalidasi” hanyalah menaikkan satu angka, tanpa perlu tahu kunci apa yang pernah ditulis. Konsekuensinya terlihat di akhir baris output — posts:v1:semua masih menempati memori sampai TTL-nya lewat, jadi pola ini cocok justru untuk kunci ber-TTL pendek.

Cache-Aside atau Write-Through #

Dua pola dasar menjaga agar cache dan data sumber tetap seirama:

AspekCache-aside (lazy)Write-through
Alur tulisTulis DB, buang cacheTulis DB, tulis juga cache
Request berikutnyaMiss dulu, lalu dihitung ulangLangsung hit dengan nilai segar
Isi cacheHanya yang benar-benar dimintaTermasuk yang belum pernah dibaca
Cocok untukMayoritas kasus, komputasi mahalData panas yang pasti dibaca lagi

Observer di atas adalah cache-aside: cache dikosongkan dan request berikutnya yang mengisi ulang. Write-through menulis nilai segar segera setelah data berubah:

php
// app/Services/PostService.php
namespace App\Services;

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

class PostService
{
    public function perbaruiJudul(Post $post, string $judul): Post
    {
        $post->update(['title' => $judul]);

        // Pola write-through: nilai segar langsung ditulis ke cache
        Cache::put("posts:detail:{$post->id}", $judul, 300);

        return $post;
    }
}

Feature test berikut membuktikan cache langsung segar tanpa satu pun komputasi ulang:

php
$post = Post::create(['title' => 'Belajar Laravel']);
$panggilan = 0;
$ambilJudul = function () use ($post, &$panggilan) {
    $panggilan++;

    return $post->fresh()->title;
};

Cache::remember("posts:detail:{$post->id}", 300, $ambilJudul);

app(PostService::class)->perbaruiJudul($post, 'Judul Direvisi');
$setelahUbah = Cache::get("posts:detail:{$post->id}");

fwrite(STDERR, "write_through: cache={$setelahUbah}"
    . " panggilan_closure={$panggilan}\n");

Output yang Dihasilkan:

text
write_through: cache=Judul Direvisi panggilan_closure=1

Closure hanya dipanggil sekali — untuk pengisian awal — karena pembaruan cache dilakukan langsung oleh service. Hati-hati dengan pembacaan $post->title di closure: nilai model di memori bisa lebih tua daripada database, sehingga fresh() menjadi keharusan di sini.

TTL sebagai Jaring Pengaman #

Invalidasi yang canggih pun tidak kebal kelalaian — test pembuka chapter tadi membuktikan query builder lolos dari observer. Di situlah TTL memainkan peran terakhirnya: membatasi seberapa lama kesalahan bisa bertahan. Feature test berikut membiarkan cache basi, lalu memajukan waktu melewati TTL:

php
$post = Post::create(['title' => 'Belajar Laravel']);
Cache::remember("posts:detail:{$post->id}", 600, fn () => $post->title);

DB::table('posts')->where('id', $post->id)->update(['title' => 'Judul Direvisi']);
$sebelumTtl = Cache::get("posts:detail:{$post->id}");

$this->travel(601)->seconds(); // maju melewati TTL 600 detik
$setelahTtl = Cache::get("posts:detail:{$post->id}");
$dihitungUlang = Cache::remember("posts:detail:{$post->id}", 600,
    fn () => DB::table('posts')->where('id', $post->id)->value('title'));

fwrite(STDERR, "ttl: sebelum={$sebelumTtl} setelah_601_detik="
    . var_export($setelahTtl, true) . " dihitung_ulang={$dihitungUlang}\n");

Output yang Dihasilkan:

text
ttl: sebelum=Belajar Laravel setelah_601_detik=NULL dihitung_ulang=Judul Direvisi

Praktik terbaiknya menggabungkan keduanya: invalidasi event untuk kesegaran seketika, TTL pendek sebagai batas kerusakan bila satu jalur perubahan terlewat. Cache lalu tetap sederhana dipakai — remember() di satu tempat — sementara kebersihannya dijaga dari dua arah. Observer yang kita tulis di sini sebenarnya adalah contoh kecil dari sistem event Laravel yang jauh lebih luas; mesinnya kita bedah tuntas di section Events dan Listeners . Rujukan resmi pola-pola di atas tersedia di dokumentasi Cache .