Pada aplikasi Laravel yang sudah mulai sibuk melayani request, bottleneck biasanya muncul di tempat yang sama: query database yang berulang, pemanggilan relasi yang mahal, dan endpoint API yang selalu menghitung data yang sebenarnya tidak sering berubah. Solusinya sering terdengar sederhana: pakai cache. Namun dalam praktiknya, cache yang dipasang sembarangan justru menambah masalah baru seperti data usang, key yang sulit dilacak, invalidasi yang berantakan, dan perilaku aplikasi yang sulit di-debug.

Di sinilah konsep cache layering berguna. Alih-alih meletakkan cache secara acak di controller atau menempelkan Cache::remember() di mana-mana, kita membangun beberapa lapisan tanggung jawab yang jelas: controller tetap tipis, service menangani use case, repository berurusan dengan sumber data, dan decorator atau lapisan cache bertugas menyimpan serta mengambil data dari cache.

Artikel ini membahas pendekatan praktis untuk membangun cache layer di Laravel, terutama untuk kebutuhan API. Kita akan melihat struktur kode, strategi key, invalidasi cache, penggunaan tag, trade-off, dan cara menghindari kesalahan umum.

Mengapa Cache Layering Dibutuhkan?

Pendekatan paling cepat biasanya seperti ini:

public function index()
{
    $users = Cache::remember('users', 600, function () {
        return User::latest()->paginate(20);
    });

    return response()->json($users);
}

Kode di atas memang bekerja, tetapi ada beberapa masalah:

  • Logika cache tersebar di controller.
  • Key cache sering tidak konsisten antar endpoint.
  • Sulit melakukan invalidasi saat data berubah.
  • Sulit diuji karena business logic dan cache bercampur.
  • Saat kebutuhan bertambah, controller akan penuh dengan aturan cache.

Dengan cache layering, kita memisahkan tanggung jawab sehingga lebih mudah dirawat. Secara umum alurnya seperti ini:

  1. Controller menerima request dan mengembalikan response.
  2. Service menjalankan use case aplikasi.
  3. Repository mengambil data dari database.
  4. Cached Repository / Cache Layer membungkus repository dan menangani cache.

Pola ini berguna jika Anda memiliki endpoint yang sering dibaca, tetapi jarang berubah, misalnya daftar produk, detail artikel, profil publik, konfigurasi aplikasi, atau statistik ringan.

Arsitektur Dasar Cache Layer di Laravel

1. Repository sebagai Kontrak Akses Data

Kita mulai dengan interface agar implementasi database dan implementasi cache dapat saling menggantikan.

<?php

namespace App\Repositories;

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use App\Models\Post;

interface PostRepositoryInterface
{
    public function paginatePublished(int $perPage = 15): LengthAwarePaginator;
    public function findPublishedBySlug(string $slug): ?Post;
}

2. Implementasi Repository Database

<?php

namespace App\Repositories;

use App\Models\Post;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class EloquentPostRepository implements PostRepositoryInterface
{
    public function paginatePublished(int $perPage = 15): LengthAwarePaginator
    {
        return Post::query()
            ->where('status', 'published')
            ->latest('published_at')
            ->paginate($perPage);
    }

    public function findPublishedBySlug(string $slug): ?Post
    {
        return Post::query()
            ->where('status', 'published')
            ->where('slug', $slug)
            ->first();
    }
}

Class ini fokus mengambil data dari database. Belum ada cache sama sekali.

3. Tambahkan Lapisan Cache

Sekarang kita buat class lain yang membungkus repository database.

<?php

namespace App\Repositories\Cached;

use App\Models\Post;
use App\Repositories\PostRepositoryInterface;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;

class CachedPostRepository implements PostRepositoryInterface
{
    public function __construct(
        protected PostRepositoryInterface $repository,
        protected CacheRepository $cache
    ) {}

    public function paginatePublished(int $perPage = 15): LengthAwarePaginator
    {
        $key = "posts:published:page:" . request('page', 1) . ":per_page:{$perPage}";

        return $this->cache->remember($key, now()->addMinutes(10), function () use ($perPage) {
            return $this->repository->paginatePublished($perPage);
        });
    }

    public function findPublishedBySlug(string $slug): ?Post
    {
        $key = "posts:published:slug:{$slug}";

        return $this->cache->remember($key, now()->addMinutes(30), function () use ($slug) {
            return $this->repository->findPublishedBySlug($slug);
        });
    }
}

Dengan pendekatan ini, implementasi database tetap bersih, sedangkan aturan cache berada di class terpisah. Ini adalah inti dari cache layering.

4. Binding di Service Container

Kita perlu mendaftarkan implementasinya ke container Laravel.

<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use App\Repositories\PostRepositoryInterface;
use App\Repositories\EloquentPostRepository;
use App\Repositories\Cached\CachedPostRepository;
use Illuminate\Contracts\Cache\Factory as CacheFactory;

class RepositoryServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(PostRepositoryInterface::class, function ($app) {
            $eloquent = new EloquentPostRepository();
            $cache = $app->make(CacheFactory::class)->store();

            return new CachedPostRepository($eloquent, $cache);
        });
    }
}

Sekarang setiap kali PostRepositoryInterface di-inject, yang dipakai adalah implementasi berlapis cache.

Menggunakan Service agar Controller Tetap Tipis

Meski repository sudah baik, sering kali use case aplikasi tetap lebih nyaman dibungkus service.

<?php

namespace App\Services;

use App\Repositories\PostRepositoryInterface;

class PostService
{
    public function __construct(protected PostRepositoryInterface $posts) {}

    public function getPublishedPosts(int $perPage = 15)
    {
        return $this->posts->paginatePublished($perPage);
    }

    public function getPublishedPostDetail(string $slug)
    {
        return $this->posts->findPublishedBySlug($slug);
    }
}

Controller menjadi sederhana:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\PostService;
use Illuminate\Http\Request;

class PostController extends Controller
{
    public function __construct(protected PostService $service) {}

    public function index(Request $request)
    {
        $posts = $this->service->getPublishedPosts((int) $request->get('per_page', 15));

        return response()->json($posts);
    }

    public function show(string $slug)
    {
        $post = $this->service->getPublishedPostDetail($slug);

        if (! $post) {
            return response()->json(['message' => 'Post tidak ditemukan'], 404);
        }

        return response()->json($post);
    }
}

Keuntungan utamanya adalah perubahan strategi cache tidak memengaruhi controller.

Strategi Key Cache yang Baik

Salah satu sumber masalah cache adalah key yang tidak disiplin. Hindari key seperti posts atau data_1 karena sulit dilacak. Buat key yang deskriptif dan konsisten.

Contoh format yang lebih baik:

  • posts:published:page:1:per_page:15
  • posts:published:slug:belajar-laravel-cache
  • users:profile:id:42
  • dashboard:stats:tenant:10

Jika response dipengaruhi parameter query lain seperti sort, filter, locale, atau tenant, masukkan semuanya ke key. Jika tidak, Anda akan mendapat cache yang salah tetapi tidak selalu terlihat salah.

Contoh pembuatan key yang lebih aman:

protected function makeListKey(array $filters): string
{
    ksort($filters);

    return 'posts:list:' . md5(json_encode($filters));
}

Pendekatan hash seperti ini berguna jika jumlah filter banyak dan panjang key berpotensi besar.

Invalidasi Cache: Bagian Tersulit

Menyimpan cache itu mudah. Menghapus cache dengan benar jauh lebih sulit. Ini sebabnya desain invalidasi harus dipikirkan sejak awal.

1. Hapus Key Spesifik Setelah Update

Jika sebuah post diperbarui, hapus cache detailnya.

Cache::forget("posts:published:slug:{$post->slug}");

Masalahnya, daftar post yang memuat item tersebut juga mungkin sudah tidak valid. Jika Anda punya banyak key list, menghapus satu per satu bisa merepotkan.

2. Gunakan Cache Tags Jika Driver Mendukung

Untuk Redis atau Memcached, tag bisa sangat membantu.

public function findPublishedBySlug(string $slug): ?Post
{
    return Cache::tags(['posts'])
        ->remember("posts:published:slug:{$slug}", now()->addMinutes(30), function () use ($slug) {
            return $this->repository->findPublishedBySlug($slug);
        });
}

Untuk list:

public function paginatePublished(int $perPage = 15)
{
    $key = "posts:published:page:" . request('page', 1) . ":per_page:{$perPage}";

    return Cache::tags(['posts'])
        ->remember($key, now()->addMinutes(10), function () use ($perPage) {
            return $this->repository->paginatePublished($perPage);
        });
}

Saat ada perubahan data:

Cache::tags(['posts'])->flush();

Catatan: cache tags tidak didukung oleh semua driver. Jika Anda memakai file cache atau database cache, cek dulu kemampuan driver yang dipakai.

3. Invalidasi dari Observer

Agar invalidasi tidak tersebar di banyak tempat, Anda bisa menaruhnya di observer model.

<?php

namespace App\Observers;

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

class PostObserver
{
    public function saved(Post $post): void
    {
        Cache::tags(['posts'])->flush();
    }

    public function deleted(Post $post): void
    {
        Cache::tags(['posts'])->flush();
    }
}

Lalu daftarkan observer tersebut.

use App\Models\Post;
use App\Observers\PostObserver;

public function boot(): void
{
    Post::observe(PostObserver::class);
}

Pendekatan ini sederhana dan aman, walaupun kadang terlalu agresif karena semua cache bertag posts akan dibersihkan.

Contoh untuk Response API yang Sudah Ditranformasi

Dalam beberapa kasus, yang mahal bukan hanya query, tetapi juga proses transformasi data menjadi JSON resource. Anda bisa menyimpan hasil akhir response yang sudah siap kirim.

public function show(string $slug)
{
    $key = "api:posts:detail:{$slug}";

    $payload = Cache::remember($key, now()->addMinutes(15), function () use ($slug) {
        $post = Post::query()
            ->with(['author:id,name', 'tags:id,name'])
            ->where('slug', $slug)
            ->where('status', 'published')
            ->firstOrFail();

        return [
            'id' => $post->id,
            'title' => $post->title,
            'slug' => $post->slug,
            'content' => $post->content,
            'author' => [
                'id' => $post->author->id,
                'name' => $post->author->name,
            ],
            'tags' => $post->tags->map(fn ($tag) => [
                'id' => $tag->id,
                'name' => $tag->name,
            ])->values(),
        ];
    });

    return response()->json($payload);
}

Ini berguna jika serialisasi juga cukup mahal. Kekurangannya, data cache jadi lebih erat ke bentuk response API. Jika format response sering berubah, invalidasi dan versi cache harus lebih disiplin.

TTL, Stale Data, dan Trade-off

TTL (time to live) tidak ada angka ajaibnya. Pilih berdasarkan sifat data:

  • 5-30 detik untuk data yang cukup dinamis tetapi sering dibaca.
  • 5-10 menit untuk list publik atau metadata yang tidak terlalu sering berubah.
  • 30-60 menit untuk detail konten yang jarang diperbarui.

Trade-off utama cache selalu sama:

  • TTL lebih lama = performa lebih baik, risiko data usang lebih tinggi.
  • TTL lebih pendek = data lebih segar, hit rate cache lebih rendah.
  • Invalidasi agresif = data lebih akurat, cache lebih sering miss.

Jangan cache semua hal. Jika query murah, jarang dipanggil, atau datanya sangat sensitif terhadap perubahan real-time, cache belum tentu memberi manfaat.

Kesalahan Umum Saat Menerapkan Cache Layer

1. Lupa Memasukkan Parameter ke Cache Key

Misalnya endpoint mendukung ?page=2&sort=popular tetapi key hanya posts:list. Hasilnya, request lain bisa menerima data yang salah.

2. Menyimpan Objek Terlalu Kompleks

Menyimpan model dengan relasi yang tidak konsisten dapat menimbulkan perilaku membingungkan setelah diambil dari cache. Untuk kebutuhan API, sering lebih aman menyimpan array yang sudah ditransformasi.

3. Mengandalkan Cache untuk Menutupi Query Buruk

Cache bukan pengganti optimasi query. Pastikan indeks database benar, eager loading dipakai saat perlu, dan query N+1 sudah diatasi. Cache harus menjadi lapisan tambahan, bukan penutup masalah desain data.

4. Tidak Punya Strategi Invalidasi

Jika Anda belum tahu kapan cache dihapus, kemungkinan besar nanti data akan basi lebih lama dari yang Anda sadari.

Debugging dan Monitoring

Saat cache terasa “tidak bekerja”, cek beberapa hal ini:

  • Pastikan CACHE_DRIVER sesuai dan benar-benar aktif.
  • Cek apakah key yang dibuat konsisten.
  • Log kapan terjadi cache hit dan miss.
  • Pastikan invalidasi tidak terlalu sering sehingga cache selalu miss.
  • Jika memakai Redis, gunakan CLI untuk melihat key yang relevan.

Contoh logging sederhana:

public function findPublishedBySlug(string $slug): ?Post
{
    $key = "posts:published:slug:{$slug}";

    if ($this->cache->has($key)) {
        logger()->info('Cache hit', ['key' => $key]);
    } else {
        logger()->info('Cache miss', ['key' => $key]);
    }

    return $this->cache->remember($key, now()->addMinutes(30), function () use ($slug) {
        return $this->repository->findPublishedBySlug($slug);
    });
}

Untuk environment lokal, Anda juga bisa memakai Laravel Telescope jika tersedia untuk melihat request, query, dan perilaku aplikasi secara lebih jelas.

Kapan Pendekatan Ini Layak Dipakai?

Cache layering layak dipakai jika:

  • Aplikasi memiliki endpoint read-heavy.
  • Data relatif stabil dibanding frekuensi pembacaan.
  • Anda ingin memisahkan concern antara akses data dan cache.
  • Tim Anda butuh kode yang lebih mudah diuji dan dirawat.

Jika aplikasinya masih kecil, satu atau dua Cache::remember() mungkin sudah cukup. Namun ketika jumlah endpoint bertambah, pola layering akan jauh lebih rapi dibanding logika cache yang tersebar di seluruh controller.

Penutup

Laravel sudah menyediakan API cache yang nyaman, tetapi hasil terbaik biasanya datang dari struktur kode yang disiplin. Dengan membangun cache sebagai lapisan tersendiri di atas repository atau service, Anda mendapatkan beberapa keuntungan sekaligus: kode lebih bersih, strategi cache lebih konsisten, invalidasi lebih mudah dipusatkan, dan debugging jadi lebih masuk akal.

Mulailah dari area yang paling sering dibaca, buat key yang konsisten, tentukan TTL berdasarkan karakter data, lalu siapkan strategi invalidasi sejak awal. Dari sana, Anda bisa mengembangkan cache layer yang sederhana tetapi efektif tanpa membuat aplikasi menjadi sulit dirawat.