Caching Strategies di Laravel: Dari Basic sampai Multi-Layer Cache

Performa website yang lambat bisa membunuh bisnis online kamu. Studi menunjukkan 53% pengguna meninggalkan website yang loading-nya lebih dari 3 detik. Satu detik delay saja bisa menurunkan conversion rate hingga 7%. Di artikel ini, kamu akan belajar strategi caching di Laravel secara lengkap — dari basic cache drivers seperti File, Redis, dan Memcached, sampai advanced techniques seperti multi-layer cache, cache warming, dan intelligent cache invalidation. Semua strategi ini sudah diimplementasikan di production BuildWithAngga, platform online course dengan 900.000+ students, dan berhasil mengurangi response time dari 2.5 detik menjadi hanya 180ms.


Bagian 1: Kenapa Caching Itu Wajib

Halo, saya Angga Risky Setiawan, founder BuildWithAngga — platform belajar coding online dengan lebih dari 900.000 students di Indonesia.

Menjalankan platform dengan traffic tinggi mengajarkan saya banyak hal tentang performa. Dulu, ketika BuildWithAngga masih punya 10.000 users, semuanya lancar. Database query cepat, server santai, users happy.

Tapi ketika users bertambah jadi 100.000, lalu 500.000, dan sekarang 900.000+, masalah performa mulai muncul satu per satu.

Database overload. Server CPU mentok 100%. Response time 5+ detik. Users complain di Discord. Google PageSpeed merah semua. SEO ranking turun.

Dari semua optimization yang saya lakukan — indexing database, query optimization, server scaling, upgrade hardware — CACHING memberikan impact paling besar dan paling cepat.

Sebelum implementasi caching yang proper:

  • Homepage load: 2.5 - 4 detik
  • Course page: 1.5 - 2 detik
  • Database queries per request: 50-100
  • Server CPU saat peak hours: 90-100%
  • Monthly server cost: $800

Setelah implementasi multi-layer caching:

  • Homepage load: 150 - 200ms
  • Course page: 100 - 150ms
  • Database queries per request: 5-10
  • Server CPU saat peak hours: 30-40%
  • Monthly server cost: $300

Improvement 10-15x lebih cepat, dengan cost yang turun hampir 3x lipat. Itu power dari caching yang diimplementasikan dengan benar.

Apa Itu Caching?

Bayangkan kamu kerja di kantor dan butuh data dari filing cabinet yang ada di gudang lantai bawah. Setiap kali butuh data, kamu harus turun tangga, cari file, bawa naik, pakai, lalu kembalikan lagi.

Capek? Lambat? Tentu.

Solusinya? Simpan copy file yang sering kamu pakai di meja kerja. Butuh data? Tinggal ambil dari meja. Jauh lebih cepat.

Itu konsep caching. Menyimpan data yang sering diakses di tempat yang lebih cepat dijangkau.

Dalam konteks web application:

  • Gudang lantai bawah = Database (MySQL, PostgreSQL)
  • Meja kerja = Cache storage (Redis, Memcached, File)
  • File yang sering dipakai = Query results, computed data, rendered views

Trade-off: Speed vs Freshness

Caching bukan tanpa konsekuensi. Ada trade-off yang harus dipahami.

Data di cache itu copy. Ketika data asli di database berubah, copy di cache bisa jadi stale (basi/outdated).

Contoh: Kamu cache daftar courses yang featured. Admin update courses featured di database. Tapi users masih lihat data lama dari cache sampai cache expired atau di-invalidate manual.

Ini yang akan kita bahas di artikel ini — bagaimana balance antara speed dan freshness, dan strategi invalidation yang tepat.

Cache Hit vs Cache Miss

Dua istilah penting yang harus kamu pahami:

Cache Hit: Data yang diminta ada di cache. Langsung return, super cepat. Response time: 1-10ms.

Cache Miss: Data tidak ada di cache. Harus query ke database, simpan ke cache, baru return. Response time: 100-1000ms+.

Goal kita adalah maximize cache hit rate. Idealnya di atas 80%.

Caching Layers

Modern web application punya multiple caching layers:

┌─────────────────────────────────────────────┐
│           BROWSER CACHE                     │
│         (User's device)                     │
│    Assets, pages, API responses             │
├─────────────────────────────────────────────┤
│            CDN CACHE                        │
│      (Cloudflare, AWS CloudFront)          │
│   Static files, edge-cached pages           │
├─────────────────────────────────────────────┤
│        APPLICATION CACHE                    │
│    (Redis, Memcached, File)                │
│   Query results, computed data              │
├─────────────────────────────────────────────┤
│         DATABASE CACHE                      │
│      (Query cache, Buffer pool)            │
│    Recent queries, hot data                 │
└─────────────────────────────────────────────┘

Request dari user akan melewati layers ini dari atas ke bawah. Semakin banyak yang bisa di-serve dari layer atas, semakin cepat response-nya.

Di artikel ini, fokus kita adalah Application Cache — layer yang paling bisa kita kontrol sebagai Laravel developer. Tapi kita juga akan bahas integration dengan CDN dan HTTP caching.

Apa yang Akan Kamu Pelajari

Di artikel ini, kita akan cover:

  1. Cache Drivers — File, Redis, Memcached, dan kapan pakai masing-masing
  2. Basic Operations — put, get, remember, forget, dan patterns yang commonly used
  3. Query Caching vs Response Caching — Dua approach berbeda untuk caching
  4. Cache Tags & Invalidation — Strategi untuk keep cache fresh
  5. Cache Warming — Proactively populate cache
  6. HTTP Caching & CDN — Browser dan edge caching
  7. Multi-Layer Cache — Complete architecture untuk high-traffic apps

Semua dengan code examples yang production-ready dan case study dari BuildWithAngga.

Mari kita mulai.


Bagian 2: Cache Drivers di Laravel

Laravel menyediakan unified API untuk berbagai cache backends. Kamu bisa switch dari File ke Redis tanpa ubah code application — cukup ubah config.

Available Cache Drivers

// config/cache.php

'stores' => [
    'file' => [...],      // Default, filesystem-based
    'redis' => [...],     // In-memory, feature-rich
    'memcached' => [...], // In-memory, distributed
    'database' => [...],  // Store di database table
    'dynamodb' => [...],  // AWS managed
    'array' => [...],     // Per-request only (testing)
],

Mari kita bahas masing-masing.

File Cache Driver

File driver menyimpan cache sebagai files di storage/framework/cache/data. Setiap cache entry jadi satu file dengan serialized content.

Kapan Pakai:

  • Development environment
  • Small applications dengan traffic rendah
  • Shared hosting yang tidak support Redis
  • Quick prototyping

Kelebihan:

  • Zero setup — works out of the box
  • No external dependencies
  • Works di mana saja PHP jalan

Kekurangan:

  • Lambat untuk high traffic (disk I/O)
  • Tidak support cache tags
  • Tidak bisa di-share antar servers
  • Garbage collection bisa problematic
// .env
CACHE_DRIVER=file

// Usage sama seperti driver lain
Cache::put('key', 'value', 3600);
$value = Cache::get('key');

Untuk production dengan traffic significant, file driver tidak recommended.

Redis Cache Driver (RECOMMENDED)

Redis adalah in-memory data store yang super cepat. Ini yang kami pakai di BuildWithAngga dan yang saya recommend untuk semua production Laravel apps.

Kapan Pakai:

  • Production applications
  • Apps dengan traffic medium-high
  • Ketika butuh cache tags
  • Ketika butuh atomic operations
  • Multi-server deployments

Kelebihan:

  • Extremely fast (in-memory)
  • Support cache tags
  • Support atomic operations (increment, locks)
  • Persistence options (data survive restart)
  • Pub/sub untuk real-time features
  • Bisa di-share antar servers

Kekurangan:

  • Butuh Redis server
  • Memory-bound (data harus fit di RAM)
  • Additional infrastructure cost

Installation:

# Install Redis server (Ubuntu)
sudo apt update
sudo apt install redis-server
sudo systemctl enable redis-server

# Install PHP Redis extension
sudo apt install php-redis

# Atau pakai predis (pure PHP)
composer require predis/predis

Configuration:

// .env
CACHE_DRIVER=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

// Untuk production, pisahkan database untuk cache
REDIS_CACHE_DB=1

// config/database.php
'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'), // atau 'predis'

    'default' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],

    'cache' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD'),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_CACHE_DB', 1), // Separate DB untuk cache
    ],
],

// config/cache.php
'stores' => [
    'redis' => [
        'driver' => 'redis',
        'connection' => 'cache', // Use 'cache' connection
        'lock_connection' => 'default',
    ],
],

Verify Redis Working:

# Test connection
redis-cli ping
# Response: PONG

# Check dari Laravel
php artisan tinker
>>> Cache::put('test', 'hello', 60);
>>> Cache::get('test');
# Response: "hello"

Memcached Driver

Memcached adalah alternative in-memory cache yang sudah mature dan battle-tested.

Kapan Pakai:

  • Distributed caching across multiple servers
  • Simple key-value caching needs
  • Legacy systems yang sudah pakai Memcached

Kelebihan:

  • Very fast
  • Distributed by design
  • Simple dan proven
  • Low memory overhead

Kekurangan:

  • No persistence (data hilang saat restart)
  • Limited data types (hanya string)
  • No cache tags di Laravel
  • Fewer features dibanding Redis
// .env
CACHE_DRIVER=memcached

// config/cache.php
'memcached' => [
    'driver' => 'memcached',
    'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
    'servers' => [
        [
            'host' => env('MEMCACHED_HOST', '127.0.0.1'),
            'port' => env('MEMCACHED_PORT', 11211),
            'weight' => 100,
        ],
    ],
],

Redis vs Memcached:

FeatureRedisMemcached
SpeedVery fastVery fast
Data typesRich (strings, lists, sets, hashes)Strings only
PersistenceYes (optional)No
Cache tagsYesNo
Pub/SubYesNo
Atomic opsYesLimited
Memory efficiencyGoodBetter

Recommendation: Pilih Redis kecuali ada alasan specific untuk Memcached.

Database Driver

Menyimpan cache di database table. Berguna ketika Redis/Memcached tidak available.

Kapan Pakai:

  • Shared hosting tanpa Redis
  • Simple apps yang tidak butuh speed extreme
  • Debugging/development

Setup:

php artisan cache:table
php artisan migrate

// .env
CACHE_DRIVER=database

Kekurangan:

  • Slower than in-memory options
  • Adds load ke database
  • Defeating the purpose jika bottleneck ada di database

Array Driver

Cache hanya persist dalam single request. Setelah request selesai, cache hilang.

Kapan Pakai:

  • Testing
  • Development ketika ingin disable caching
// .env (testing)
CACHE_DRIVER=array

BuildWithAngga Setup: Hybrid Approach

Di BuildWithAngga, kami pakai hybrid approach:

// config/cache.php
'default' => env('CACHE_DRIVER', 'redis'),

'stores' => [
    // Primary cache - Redis
    'redis' => [
        'driver' => 'redis',
        'connection' => 'cache',
    ],

    // Secondary for specific use cases
    'file' => [
        'driver' => 'file',
        'path' => storage_path('framework/cache/data'),
    ],

    // For very long-term, rarely-changing data
    'redis_permanent' => [
        'driver' => 'redis',
        'connection' => 'cache_permanent',
    ],
],

Kenapa Hybrid?

  1. Redis untuk most caches — fast, supports tags
  2. File untuk fallback dan specific cases
  3. Separate Redis DB untuk permanent data yang jarang berubah
// Usage dengan specific store
Cache::store('redis')->put('key', 'value', 3600);
Cache::store('file')->put('config_backup', $config, 86400);
Cache::store('redis_permanent')->forever('static_data', $data);

Quick Reference: Choosing a Driver

DECISION TREE:

Production app dengan traffic?
├── YES → Redis (recommended)
│   ├── Need distributed? → Redis Cluster atau Memcached
│   └── Single server? → Single Redis instance
│
└── NO (development/small app)
    ├── Redis available? → Still use Redis
    └── No Redis? → File driver (atau Database)

Testing?
└── Array driver

Di bagian selanjutnya, kita akan explore basic caching operations dan patterns yang paling commonly used.

Bagian 3: Basic Caching Operations

Sekarang kita masuk ke hands-on. Laravel menyediakan Cache facade dengan API yang intuitive untuk semua caching operations.

Cache Facade Methods

use Illuminate\\Support\\Facades\\Cache;

// Atau dengan helper function
cache('key'); // Get
cache(['key' => 'value'], 3600); // Put

put() — Menyimpan Data

// Simpan dengan TTL (Time To Live) dalam detik
Cache::put('user_count', 1500, 3600); // 1 jam

// Dengan Carbon untuk readability
Cache::put('featured_courses', $courses, now()->addHours(2));
Cache::put('daily_stats', $stats, now()->addDay());
Cache::put('settings', $settings, now()->addWeek());

// Tanpa TTL — sampai di-delete manual atau cache di-flush
Cache::put('permanent_key', 'value');

get() — Mengambil Data

// Basic get (return null jika tidak ada)
$value = Cache::get('user_count');

// Dengan default value
$value = Cache::get('user_count', 0);

// Dengan closure untuk default (lazy evaluation)
$value = Cache::get('user_count', function () {
    return User::count(); // Hanya execute jika cache miss
});

remember() — Get atau Set (PALING SERING DIPAKAI)

Ini adalah pattern yang paling commonly used. Check cache dulu, jika tidak ada baru execute closure dan simpan hasilnya.

// Pattern: Cache::remember($key, $ttl, $callback)
$courses = Cache::remember('featured_courses', 3600, function () {
    return Course::with(['instructor', 'category'])
        ->where('is_featured', true)
        ->where('is_published', true)
        ->orderBy('enrolled_count', 'desc')
        ->limit(12)
        ->get();
});

Bagaimana cara kerjanya:

  1. Check apakah featured_courses ada di cache
  2. Jika HIT → Return cached value, closure tidak di-execute
  3. Jika MISS → Execute closure, simpan result ke cache, return result

Ini satu line menggantikan pattern manual:

// Tanpa remember (verbose)
$courses = Cache::get('featured_courses');

if ($courses === null) {
    $courses = Course::with(['instructor', 'category'])
        ->where('is_featured', true)
        ->where('is_published', true)
        ->orderBy('enrolled_count', 'desc')
        ->limit(12)
        ->get();

    Cache::put('featured_courses', $courses, 3600);
}

// Dengan remember (clean)
$courses = Cache::remember('featured_courses', 3600, function () {
    return Course::with(['instructor', 'category'])
        ->where('is_featured', true)
        ->where('is_published', true)
        ->orderBy('enrolled_count', 'desc')
        ->limit(12)
        ->get();
});

rememberForever() — Cache Tanpa Expiration

// Untuk data yang sangat jarang berubah
$categories = Cache::rememberForever('all_categories', function () {
    return Category::where('is_active', true)
        ->orderBy('name')
        ->get();
});

$settings = Cache::rememberForever('app_settings', function () {
    return Setting::pluck('value', 'key')->toArray();
});

Warning: Data ini tidak akan expired otomatis. Kamu harus manual invalidate ketika data berubah.

forget() — Hapus Specific Cache

// Hapus single key
Cache::forget('featured_courses');

// Hapus multiple keys
$keysToForget = ['featured_courses', 'new_courses', 'homepage_stats'];
foreach ($keysToForget as $key) {
    Cache::forget($key);
}

flush() — Hapus Semua Cache

// HATI-HATI! Ini menghapus SEMUA cache
Cache::flush();

// Di production, lebih baik pakai cache tags (akan dibahas nanti)

has() — Check Existence

if (Cache::has('featured_courses')) {
    // Key exists dan value bukan null
}

// Atau dengan missing()
if (Cache::missing('featured_courses')) {
    // Key tidak ada
}

add() — Store Only If Not Exists

// Hanya simpan jika key belum ada
// Return true jika berhasil, false jika key sudah ada
$added = Cache::add('lock_key', 'locked', 60);

if ($added) {
    // Berhasil acquire lock
} else {
    // Key sudah ada, orang lain sudah acquire
}

Berguna untuk simple locking mechanism.


Cache Keys Best Practices

Cache key yang baik itu descriptive, unique, dan consistent.

Naming Convention:

// Format: entity:identifier:detail
'course:123:full'           // Course dengan ID 123, full data
'courses:featured'          // List featured courses
'courses:category:5'        // Courses di category ID 5
'user:456:enrolled_courses' // Enrolled courses untuk user 456
'homepage:stats'            // Stats untuk homepage

Dynamic Keys dengan Parameters:

class CourseRepository
{
    public function findWithDetails(int $courseId): ?Course
    {
        $cacheKey = "course:{$courseId}:full";

        return Cache::remember($cacheKey, 1800, function () use ($courseId) {
            return Course::with([
                'instructor.user',
                'category',
                'lessons',
                'reviews' => fn($q) => $q->latest()->limit(10),
            ])->find($courseId);
        });
    }

    public function getByCategory(int $categoryId, int $page = 1): LengthAwarePaginator
    {
        $cacheKey = "courses:category:{$categoryId}:page:{$page}";

        return Cache::remember($cacheKey, 3600, function () use ($categoryId) {
            return Course::where('category_id', $categoryId)
                ->where('is_published', true)
                ->orderBy('created_at', 'desc')
                ->paginate(12);
        });
    }
}

Cache Key Versioning:

Ketika format data berubah, kamu perlu invalidate old cache. Cara termudah adalah dengan versioning.

class CacheKeyGenerator
{
    private const VERSION = 'v2'; // Increment ketika format berubah

    public static function forCourse(int $courseId): string
    {
        return self::VERSION . ":course:{$courseId}:full";
    }

    public static function forFeaturedCourses(): string
    {
        return self::VERSION . ":courses:featured";
    }

    public static function forCategory(int $categoryId): string
    {
        return self::VERSION . ":courses:category:{$categoryId}";
    }
}

// Usage
$cacheKey = CacheKeyGenerator::forCourse($courseId);
$course = Cache::remember($cacheKey, 1800, fn() => $this->fetchCourse($courseId));

Ketika kamu ubah VERSION dari 'v1' ke 'v2', semua old cache otomatis "expired" karena key-nya berbeda.


Cache Duration Strategies

TTL (Time To Live) yang tepat depends on seberapa sering data berubah dan seberapa critical freshness-nya.

// Real-time critical — 1-5 menit
// Data: stock availability, cart count, online users
Cache::put('product:123:stock', $stock, now()->addMinutes(1));

// Semi-dynamic — 15-60 menit
// Data: course details, user profiles, search results
Cache::put('course:456:full', $course, now()->addMinutes(30));

// Slow-changing — 1-24 jam
// Data: featured lists, category listings, statistics
Cache::put('courses:featured', $courses, now()->addHours(2));

// Static — days sampai weeks
// Data: navigation menus, config, static pages
Cache::put('navigation:main', $menu, now()->addDays(7));

// Forever — manual invalidation
// Data: app settings, translation strings
Cache::forever('app:settings', $settings);

BuildWithAngga TTL Strategy:

class CacheTTL
{
    // Real-time
    public const CART = 60;              // 1 menit
    public const ONLINE_COUNT = 120;     // 2 menit

    // Dynamic
    public const COURSE_DETAIL = 1800;   // 30 menit
    public const USER_PROFILE = 3600;    // 1 jam
    public const SEARCH_RESULTS = 900;   // 15 menit

    // Semi-static
    public const FEATURED_COURSES = 7200;  // 2 jam
    public const CATEGORY_COURSES = 3600;  // 1 jam
    public const HOMEPAGE_STATS = 3600;    // 1 jam

    // Static
    public const NAVIGATION = 86400;     // 1 hari
    public const CATEGORIES = 86400;     // 1 hari
    public const SETTINGS = null;        // Forever
}

// Usage
Cache::remember('courses:featured', CacheTTL::FEATURED_COURSES, fn() => ...);


Atomic Operations

Redis dan Memcached support atomic operations yang berguna untuk counters dan locking.

increment() / decrement():

// View counter (atomic, no race condition)
Cache::increment("course:{$courseId}:views");
Cache::increment("course:{$courseId}:views", 5); // Tambah 5

// Download counter
Cache::decrement("file:{$fileId}:remaining_downloads");

// Get current value
$views = Cache::get("course:{$courseId}:views", 0);

Cache Locks:

Prevent race conditions ketika multiple processes mencoba execute code yang sama.

use Illuminate\\Support\\Facades\\Cache;

// Simple lock
$lock = Cache::lock('process-payment-' . $orderId, 10); // 10 detik

if ($lock->get()) {
    try {
        // Process payment — hanya 1 process yang bisa masuk sini
        $this->processPayment($order);
    } finally {
        $lock->release();
    }
} else {
    // Lock sudah di-hold process lain
    throw new PaymentInProgressException();
}

// Block dan tunggu sampai lock available
$lock = Cache::lock('heavy-process', 30);

$lock->block(10, function () {
    // Tunggu max 10 detik untuk acquire lock
    // Kalau dapat, execute ini
    $this->doHeavyProcess();
});

Real Example — Prevent Double Enrollment:

class EnrollmentService
{
    public function enroll(User $user, Course $course): Enrollment
    {
        $lockKey = "enroll:{$user->id}:{$course->id}";
        $lock = Cache::lock($lockKey, 30);

        if (!$lock->get()) {
            throw new EnrollmentInProgressException(
                'Enrollment sedang diproses, mohon tunggu.'
            );
        }

        try {
            // Check apakah sudah enrolled
            if ($user->enrolledCourses()->where('course_id', $course->id)->exists()) {
                throw new AlreadyEnrolledException();
            }

            // Process enrollment
            $enrollment = Enrollment::create([
                'user_id' => $user->id,
                'course_id' => $course->id,
                'enrolled_at' => now(),
            ]);

            // Clear related caches
            Cache::forget("user:{$user->id}:enrolled_courses");
            Cache::increment("course:{$course->id}:enrolled_count");

            return $enrollment;

        } finally {
            $lock->release();
        }
    }
}


Bagian 4: Query Caching vs Response Caching

Ada dua approach utama untuk caching di Laravel application: Query Caching dan Response Caching. Keduanya punya use case yang berbeda.

Query Caching

Apa itu: Cache hasil database query. Granular, per-query level.

Kapan pakai:

  • Expensive queries (complex joins, aggregations)
  • Frequently repeated queries
  • Data yang dipakai di multiple places
  • Dynamic pages yang per-user berbeda tapi share some data

Implementation Pattern — Repository:

class CourseRepository
{
    public function getFeatured(): Collection
    {
        return Cache::remember('courses:featured', CacheTTL::FEATURED_COURSES, function () {
            return Course::with(['instructor', 'category'])
                ->where('is_featured', true)
                ->where('is_published', true)
                ->orderBy('enrolled_count', 'desc')
                ->limit(12)
                ->get();
        });
    }

    public function getNew(int $limit = 8): Collection
    {
        return Cache::remember("courses:new:{$limit}", CacheTTL::FEATURED_COURSES, function () use ($limit) {
            return Course::with(['instructor', 'category'])
                ->where('is_published', true)
                ->orderBy('created_at', 'desc')
                ->limit($limit)
                ->get();
        });
    }

    public function getByCategory(int $categoryId): Collection
    {
        $cacheKey = "courses:category:{$categoryId}";

        return Cache::remember($cacheKey, CacheTTL::CATEGORY_COURSES, function () use ($categoryId) {
            return Course::with(['instructor'])
                ->where('category_id', $categoryId)
                ->where('is_published', true)
                ->orderBy('enrolled_count', 'desc')
                ->get();
        });
    }

    public function findWithDetails(int $id): ?Course
    {
        $cacheKey = "course:{$id}:full";

        return Cache::remember($cacheKey, CacheTTL::COURSE_DETAIL, function () use ($id) {
            return Course::with([
                'instructor.user',
                'category',
                'lessons' => fn($q) => $q->orderBy('order'),
                'reviews' => fn($q) => $q->with('user')->latest()->limit(10),
            ])
            ->withCount(['enrollments', 'reviews', 'lessons'])
            ->find($id);
        });
    }

    public function getStats(): array
    {
        return Cache::remember('homepage:stats', CacheTTL::HOMEPAGE_STATS, function () {
            return [
                'total_courses' => Course::where('is_published', true)->count(),
                'total_students' => User::where('role', 'student')->count(),
                'total_enrollments' => Enrollment::count(),
                'total_instructors' => User::where('role', 'instructor')->count(),
            ];
        });
    }
}

Usage di Controller:

class HomeController extends Controller
{
    public function __construct(
        private CourseRepository $courseRepo,
        private CategoryRepository $categoryRepo
    ) {}

    public function index()
    {
        return view('home', [
            'featuredCourses' => $this->courseRepo->getFeatured(),
            'newCourses' => $this->courseRepo->getNew(8),
            'categories' => $this->categoryRepo->getPopular(),
            'stats' => $this->courseRepo->getStats(),
        ]);
    }
}

Kelebihan Query Caching:

  • Granular control per query
  • Data bisa di-reuse di multiple pages
  • Bisa combine cached dan non-cached data
  • Works untuk authenticated users

Kekurangan:

  • Harus implement manual di setiap query
  • Perlu manage invalidation per key

Response Caching

Apa itu: Cache entire HTTP response (full rendered HTML atau JSON). Page-level caching.

Kapan pakai:

  • Static atau semi-static pages
  • Pages yang sama untuk semua users (guests)
  • API endpoints yang jarang berubah
  • Content-heavy pages

Package: spatie/laravel-responsecache

composer require spatie/laravel-responsecache
php artisan vendor:publish --provider="Spatie\\ResponseCache\\ResponseCacheServiceProvider"

Setup Middleware:

// app/Http/Kernel.php
protected $middlewareGroups = [
    'web' => [
        // ... other middleware
        \\Spatie\\ResponseCache\\Middlewares\\CacheResponse::class,
    ],
];

// Atau per-route
protected $middlewareAliases = [
    'cacheResponse' => \\Spatie\\ResponseCache\\Middlewares\\CacheResponse::class,
    'doNotCacheResponse' => \\Spatie\\ResponseCache\\Middlewares\\DoNotCacheResponse::class,
];

Route-level Caching:

// routes/web.php

// Cache selama 1 jam (3600 detik)
Route::get('/', [HomeController::class, 'index'])
    ->middleware('cacheResponse:3600');

Route::get('/courses', [CourseController::class, 'index'])
    ->middleware('cacheResponse:1800');

Route::get('/courses/{course:slug}', [CourseController::class, 'show'])
    ->middleware('cacheResponse:1800');

// Exclude dari cache
Route::get('/cart', [CartController::class, 'index'])
    ->middleware('doNotCacheResponse');

Route::get('/dashboard', [DashboardController::class, 'index'])
    ->middleware(['auth', 'doNotCacheResponse']);

// API responses
Route::get('/api/courses/featured', [ApiCourseController::class, 'featured'])
    ->middleware('cacheResponse:3600');

Custom Cache Profile:

Control lebih granular kapan cache dan kapan tidak.

// app/CacheProfiles/GuestOnlyCacheProfile.php
namespace App\\CacheProfiles;

use Illuminate\\Http\\Request;
use Spatie\\ResponseCache\\CacheProfiles\\BaseCacheProfile;

class GuestOnlyCacheProfile extends BaseCacheProfile
{
    public function shouldCacheRequest(Request $request): bool
    {
        // Jangan cache untuk authenticated users
        if (auth()->check()) {
            return false;
        }

        // Hanya cache GET requests
        if (!$request->isMethod('GET')) {
            return false;
        }

        // Jangan cache jika ada query string tertentu
        if ($request->has('preview')) {
            return false;
        }

        return true;
    }

    public function cacheRequestUntil(Request $request): \\DateTime
    {
        // Dynamic TTL based on route
        $ttl = match(true) {
            $request->is('/') => 3600,           // Homepage: 1 jam
            $request->is('courses') => 1800,      // Course list: 30 menit
            $request->is('courses/*') => 1800,    // Course detail: 30 menit
            $request->is('categories/*') => 3600, // Category: 1 jam
            default => 900,                       // Default: 15 menit
        };

        return now()->addSeconds($ttl);
    }

    public function useCacheNameSuffix(Request $request): string
    {
        // Differentiate cache by locale
        return app()->getLocale();
    }
}

// config/responsecache.php
'cache_profile' => App\\CacheProfiles\\GuestOnlyCacheProfile::class,

Clear Response Cache:

use Spatie\\ResponseCache\\Facades\\ResponseCache;

// Clear all response cache
ResponseCache::clear();

// Clear specific URL
ResponseCache::forget('/courses/laravel-101');

// Dalam Observer
class CourseObserver
{
    public function saved(Course $course): void
    {
        ResponseCache::forget("/courses/{$course->slug}");
        ResponseCache::forget('/courses');
        ResponseCache::forget('/');
    }
}

Kelebihan Response Caching:

  • Massive performance gain (skip all PHP processing)
  • Simple setup
  • Good untuk content sites

Kekurangan:

  • Tidak cocok untuk authenticated/personalized pages
  • All-or-nothing (whole page cached)
  • Stale data risk lebih tinggi

Comparison: Query vs Response Caching

AspectQuery CachingResponse Caching
GranularityPer queryPer page
FlexibilityHighMedium
ImplementationManualAutomatic
Cache sizeSmallerLarger
Hit rateVariesHigh
PersonalizationSupportsDoesn't support
Best forDynamic pagesStatic pages

BuildWithAngga Strategy: Combining Both

Di BuildWithAngga, kami pakai keduanya:

RESPONSE CACHE (untuk guests):
├── Homepage                    → 1 jam
├── Course catalog              → 30 menit
├── Course detail (public)      → 30 menit
├── Category pages              → 1 jam
├── Blog posts                  → 2 jam
└── Static pages (about, etc)   → 24 jam

NO RESPONSE CACHE:
├── Dashboard                   → Query cache only
├── User profile                → Query cache only
├── Cart & Checkout             → No cache
├── My courses                  → Query cache only
└── Admin pages                 → No cache

QUERY CACHE (selalu):
├── Featured courses            → 2 jam
├── New courses                 → 2 jam
├── Category listings           → 1 jam
├── Course details              → 30 menit
├── Instructor profiles         → 1 jam
├── Navigation menus            → 24 jam
└── Site statistics             → 1 jam

Code Example — Combined Approach:

// Controller dengan query cache
class CourseController extends Controller
{
    public function show(string $slug)
    {
        // Query cache untuk course data
        $course = Cache::remember("course:slug:{$slug}", 1800, function () use ($slug) {
            return Course::with([
                'instructor.user',
                'category',
                'lessons',
            ])
            ->where('slug', $slug)
            ->where('is_published', true)
            ->firstOrFail();
        });

        // Query cache untuk related courses
        $relatedCourses = Cache::remember(
            "courses:related:{$course->category_id}:{$course->id}",
            3600,
            function () use ($course) {
                return Course::where('category_id', $course->category_id)
                    ->where('id', '!=', $course->id)
                    ->where('is_published', true)
                    ->limit(4)
                    ->get();
            }
        );

        return view('courses.show', compact('course', 'relatedCourses'));
    }
}

// Route dengan response cache (untuk guests)
Route::get('/courses/{slug}', [CourseController::class, 'show'])
    ->middleware('cacheResponse:1800')
    ->name('courses.show');

Dengan setup ini:

  • Guests dapat full response cache (super fast)
  • Authenticated users skip response cache, tapi masih benefit dari query cache
  • Data consistency maintained via cache tags dan invalidation (akan dibahas di bagian selanjutnya)

Di bagian selanjutnya, kita akan deep dive ke Cache Tags dan Invalidation Strategies — crucial untuk keeping cache fresh tanpa sacrificing performance.

Bagian 5: Cache Tags dan Invalidation Strategies

Cache yang tidak di-invalidate dengan benar adalah sumber bug yang susah di-debug. Users lihat data lama, admin bingung kenapa update tidak muncul, developers frustrasi.

Di bagian ini, kita akan bahas strategi untuk keep cache fresh.

Cache Tags (Redis/Memcached Only)

Cache tags memungkinkan kamu grouping cache entries dan invalidate mereka secara bersamaan. Ini game-changer untuk cache management.

Note: Tags hanya available untuk Redis dan Memcached. File driver tidak support tags.

Basic Syntax:

// Storing dengan tags
Cache::tags(['courses', 'homepage'])->put('featured_courses', $courses, 3600);

// Retrieving (tags harus sama)
$courses = Cache::tags(['courses', 'homepage'])->get('featured_courses');

// Remember dengan tags
$courses = Cache::tags(['courses', 'featured'])->remember(
    'featured_courses',
    3600,
    fn() => Course::featured()->get()
);

// Flush by tag — menghapus SEMUA cache dengan tag tersebut
Cache::tags(['courses'])->flush();      // Clear semua course-related cache
Cache::tags(['homepage'])->flush();     // Clear semua homepage cache
Cache::tags(['courses', 'homepage'])->flush();  // Clear yang punya KEDUA tags

Tagging Strategy:

Saya recommend multi-level tagging:

// Level 1: Entity type
Cache::tags(['courses'])->put('courses:all', $courses, 3600);

// Level 2: Entity type + specific ID
Cache::tags(['courses', "course:{$courseId}"])->put(
    "course:{$courseId}:full",
    $course,
    1800
);

// Level 3: Entity + Related entities
Cache::tags(['courses', "course:{$courseId}", "category:{$categoryId}"])->put(
    "course:{$courseId}:with_category",
    $courseWithCategory,
    1800
);

// Level 4: Feature-based
Cache::tags(['courses', 'homepage', 'featured'])->put(
    'homepage:featured_courses',
    $featured,
    3600
);

Complete Tagging Example:

class CourseRepository
{
    public function getFeatured(): Collection
    {
        return Cache::tags(['courses', 'homepage', 'featured'])
            ->remember('courses:featured', CacheTTL::FEATURED_COURSES, function () {
                return Course::with(['instructor', 'category'])
                    ->where('is_featured', true)
                    ->where('is_published', true)
                    ->orderBy('enrolled_count', 'desc')
                    ->limit(12)
                    ->get();
            });
    }

    public function getByCategory(int $categoryId): Collection
    {
        return Cache::tags(['courses', "category:{$categoryId}"])
            ->remember("courses:category:{$categoryId}", CacheTTL::CATEGORY_COURSES, function () use ($categoryId) {
                return Course::with(['instructor'])
                    ->where('category_id', $categoryId)
                    ->where('is_published', true)
                    ->orderBy('enrolled_count', 'desc')
                    ->get();
            });
    }

    public function findWithDetails(int $id): ?Course
    {
        $course = Course::find($id);
        if (!$course) return null;

        return Cache::tags(['courses', "course:{$id}", "category:{$course->category_id}"])
            ->remember("course:{$id}:full", CacheTTL::COURSE_DETAIL, function () use ($id) {
                return Course::with([
                    'instructor.user',
                    'category',
                    'lessons' => fn($q) => $q->orderBy('order'),
                    'reviews' => fn($q) => $q->with('user')->latest()->limit(10),
                ])
                ->withCount(['enrollments', 'reviews', 'lessons'])
                ->find($id);
            });
    }

    public function getInstructorCourses(int $instructorId): Collection
    {
        return Cache::tags(['courses', "instructor:{$instructorId}"])
            ->remember("courses:instructor:{$instructorId}", CacheTTL::CATEGORY_COURSES, function () use ($instructorId) {
                return Course::where('instructor_id', $instructorId)
                    ->where('is_published', true)
                    ->orderBy('created_at', 'desc')
                    ->get();
            });
    }
}


Cache Invalidation Strategies

Strategy 1: Time-Based Expiration (TTL)

Paling simple — biarkan cache expired sendiri berdasarkan TTL.

Cache::put('featured_courses', $courses, 3600); // Expired setelah 1 jam

Pros: Simple, no extra code Cons: Data bisa stale sampai TTL habis

Best for: Data yang acceptable jika sedikit outdated (stats, listings)

Strategy 2: Event-Based Invalidation

Clear cache ketika data berubah, menggunakan Model Observers.

// app/Observers/CourseObserver.php
class CourseObserver
{
    public function __construct(
        private CacheInvalidationService $cacheService
    ) {}

    public function saved(Course $course): void
    {
        $this->cacheService->invalidateCourse($course);
    }

    public function deleted(Course $course): void
    {
        $this->cacheService->invalidateCourse($course);
    }
}

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    Course::observe(CourseObserver::class);
    Category::observe(CategoryObserver::class);
    Review::observe(ReviewObserver::class);
}

CacheInvalidationService:

// app/Services/CacheInvalidationService.php
class CacheInvalidationService
{
    public function invalidateCourse(Course $course): void
    {
        // Clear specific course cache
        Cache::tags(["course:{$course->id}"])->flush();

        // Clear category listing (course mungkin muncul di sini)
        Cache::tags(["category:{$course->category_id}"])->flush();

        // Clear instructor's course list
        Cache::tags(["instructor:{$course->instructor_id}"])->flush();

        // Clear homepage jika course featured
        if ($course->is_featured) {
            Cache::tags(['homepage'])->flush();
            Cache::tags(['featured'])->flush();
        }

        // Clear response cache untuk specific URLs
        $this->clearResponseCache($course);

        // Log untuk debugging
        Log::info('Course cache invalidated', [
            'course_id' => $course->id,
            'course_title' => $course->title,
        ]);
    }

    public function invalidateCategory(Category $category): void
    {
        // Clear category-related cache
        Cache::tags(["category:{$category->id}"])->flush();

        // Clear navigation (categories muncul di nav)
        Cache::tags(['navigation'])->flush();

        // Clear homepage (categories muncul di homepage)
        Cache::tags(['homepage'])->flush();
    }

    public function invalidateReview(Review $review): void
    {
        // Clear course cache (review count/rating berubah)
        Cache::tags(["course:{$review->course_id}"])->flush();
    }

    public function invalidateUser(User $user): void
    {
        Cache::tags(["user:{$user->id}"])->flush();

        // Jika user adalah instructor, clear course cache juga
        if ($user->role === 'instructor') {
            Cache::tags(["instructor:{$user->id}"])->flush();
        }
    }

    private function clearResponseCache(Course $course): void
    {
        if (class_exists(\\Spatie\\ResponseCache\\Facades\\ResponseCache::class)) {
            ResponseCache::forget("/courses/{$course->slug}");
            ResponseCache::forget('/courses');
            ResponseCache::forget('/');
        }
    }

    public function invalidateAll(): void
    {
        Cache::tags(['courses'])->flush();
        Cache::tags(['categories'])->flush();
        Cache::tags(['homepage'])->flush();
        Cache::tags(['navigation'])->flush();

        if (class_exists(\\Spatie\\ResponseCache\\Facades\\ResponseCache::class)) {
            ResponseCache::clear();
        }

        Log::warning('All application cache invalidated');
    }
}

Observer untuk Related Models:

// app/Observers/ReviewObserver.php
class ReviewObserver
{
    public function __construct(
        private CacheInvalidationService $cacheService
    ) {}

    public function saved(Review $review): void
    {
        $this->cacheService->invalidateReview($review);
    }

    public function deleted(Review $review): void
    {
        $this->cacheService->invalidateReview($review);
    }
}

// app/Observers/LessonObserver.php
class LessonObserver
{
    public function saved(Lesson $lesson): void
    {
        // Clear parent course cache
        Cache::tags(["course:{$lesson->course_id}"])->flush();
    }
}

Strategy 3: Manual Invalidation

Untuk admin actions atau bulk operations.

// app/Http/Controllers/Admin/CourseController.php
class CourseController extends Controller
{
    public function update(Request $request, Course $course)
    {
        $course->update($validated);

        // Observer akan handle cache invalidation
        // Tapi bisa juga explicit
        app(CacheInvalidationService::class)->invalidateCourse($course);

        return redirect()->back()->with('success', 'Course updated!');
    }

    public function bulkPublish(Request $request)
    {
        $courseIds = $request->input('course_ids');

        Course::whereIn('id', $courseIds)->update(['is_published' => true]);

        // Bulk invalidation
        foreach ($courseIds as $courseId) {
            Cache::tags(["course:{$courseId}"])->flush();
        }

        // Clear listings
        Cache::tags(['courses'])->flush();
        Cache::tags(['homepage'])->flush();

        return response()->json(['success' => true]);
    }

    public function clearAllCache()
    {
        app(CacheInvalidationService::class)->invalidateAll();

        return redirect()->back()->with('success', 'All cache cleared!');
    }
}

Strategy 4: Version-Based Invalidation

Alternative tanpa cache tags — useful untuk file driver.

class VersionedCacheService
{
    public function remember(string $key, int $ttl, Closure $callback)
    {
        $version = $this->getVersion($key);
        $versionedKey = "v{$version}:{$key}";

        return Cache::remember($versionedKey, $ttl, $callback);
    }

    public function invalidate(string $key): void
    {
        // Increment version — old cache otomatis tidak dipakai
        Cache::increment($this->getVersionKey($key));
    }

    private function getVersion(string $key): int
    {
        return (int) Cache::get($this->getVersionKey($key), 1);
    }

    private function getVersionKey(string $key): string
    {
        // Extract entity type dari key
        // "course:123:full" → "version:course"
        $parts = explode(':', $key);
        $entity = $parts[0] ?? 'default';

        return "version:{$entity}";
    }
}

// Usage
$cacheService = app(VersionedCacheService::class);

// Store
$course = $cacheService->remember("course:{$id}:full", 3600, fn() => $this->fetchCourse($id));

// Invalidate semua course cache
$cacheService->invalidate('course');


BuildWithAngga Invalidation Flow

┌─────────────────────────────────────────────────────────┐
│                   ADMIN UPDATE COURSE                   │
└────────────────────────┬────────────────────────────────┘
                         ▼
┌─────────────────────────────────────────────────────────┐
│                 MODEL OBSERVER TRIGGERED                │
│                   CourseObserver::saved()               │
└────────────────────────┬────────────────────────────────┘
                         ▼
┌─────────────────────────────────────────────────────────┐
│              CACHE INVALIDATION SERVICE                 │
│                                                         │
│  1. Clear course:{id} tags                              │
│  2. Clear category:{categoryId} tags                    │
│  3. Clear instructor:{instructorId} tags                │
│  4. Clear homepage tags (jika featured)                 │
│  5. Clear response cache untuk URLs terkait             │
│  6. Log invalidation untuk audit                        │
└────────────────────────┬────────────────────────────────┘
                         ▼
┌─────────────────────────────────────────────────────────┐
│                OPTIONAL: CACHE WARMING                  │
│                                                         │
│  Dispatch job untuk pre-populate cache baru             │
│  (Akan dibahas di bagian selanjutnya)                   │
└─────────────────────────────────────────────────────────┘


Common Pitfalls & Solutions

Pitfall 1: Forget to Invalidate Related Cache

// ❌ BAD — Hanya clear course, lupa related
public function saved(Course $course): void
{
    Cache::forget("course:{$course->id}:full");
    // Category listing masih show old data!
}

// ✅ GOOD — Clear all related
public function saved(Course $course): void
{
    Cache::tags(["course:{$course->id}"])->flush();
    Cache::tags(["category:{$course->category_id}"])->flush();
    if ($course->is_featured) {
        Cache::tags(['homepage'])->flush();
    }
}

Pitfall 2: Over-Invalidation

// ❌ BAD — Flush everything on any change
public function saved(Course $course): void
{
    Cache::flush(); // Nuclear option — DON'T DO THIS
}

// ✅ GOOD — Surgical invalidation
public function saved(Course $course): void
{
    // Only clear what's affected
    Cache::tags(["course:{$course->id}"])->flush();

    // Check if featured status changed
    if ($course->wasChanged('is_featured')) {
        Cache::tags(['homepage', 'featured'])->flush();
    }

    // Check if category changed
    if ($course->wasChanged('category_id')) {
        Cache::tags(["category:{$course->getOriginal('category_id')}"])->flush();
        Cache::tags(["category:{$course->category_id}"])->flush();
    }
}

Pitfall 3: Not Handling Soft Deletes

// Model dengan SoftDeletes
class Course extends Model
{
    use SoftDeletes;
}

// Observer harus handle restore juga
class CourseObserver
{
    public function restored(Course $course): void
    {
        // Same as saved
        app(CacheInvalidationService::class)->invalidateCourse($course);
    }

    public function forceDeleted(Course $course): void
    {
        // Permanent delete
        app(CacheInvalidationService::class)->invalidateCourse($course);
    }
}


Bagian 6: Cache Warming dan Preloading

Cache warming adalah proses proactively mengisi cache sebelum users request data. Ini menghindari "cold cache" problem — dimana first request setelah cache clear atau deployment jadi sangat lambat.

Cold Cache Problem

SCENARIO: Setelah deployment, cache di-clear

Request 1 (User A): Homepage
├── Cache MISS untuk featured_courses → 500ms query
├── Cache MISS untuk new_courses → 300ms query
├── Cache MISS untuk categories → 200ms query
├── Cache MISS untuk stats → 400ms query
└── Total: 1.5+ detik (SLOW!)

Request 2-1000 (User B, C, D...): Homepage
├── Cache HIT untuk semua
└── Total: 100-200ms (FAST!)

User A: "Website ini lambat!"

Solution: Warm cache sebelum users hit.

Artisan Command untuk Cache Warming

// app/Console/Commands/WarmCacheCommand.php
namespace App\\Console\\Commands;

use App\\Repositories\\CourseRepository;
use App\\Repositories\\CategoryRepository;
use App\\Repositories\\InstructorRepository;
use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\Cache;

class WarmCacheCommand extends Command
{
    protected $signature = 'cache:warm
                            {--section= : Specific section to warm (homepage, courses, categories)}
                            {--queue : Dispatch to queue instead of sync}';

    protected $description = 'Warm up application cache';

    public function __construct(
        private CourseRepository $courseRepo,
        private CategoryRepository $categoryRepo,
        private InstructorRepository $instructorRepo
    ) {
        parent::__construct();
    }

    public function handle(): int
    {
        $section = $this->option('section');
        $useQueue = $this->option('queue');

        $this->info('🔥 Starting cache warming...');
        $startTime = microtime(true);

        if ($useQueue) {
            $this->dispatchToQueue($section);
        } else {
            $this->warmSync($section);
        }

        $duration = round(microtime(true) - $startTime, 2);
        $this->info("✅ Cache warming completed in {$duration}s");

        return Command::SUCCESS;
    }

    private function warmSync(?string $section): void
    {
        $sections = $section
            ? [$section]
            : ['homepage', 'courses', 'categories', 'navigation'];

        foreach ($sections as $sec) {
            $method = 'warm' . ucfirst($sec);
            if (method_exists($this, $method)) {
                $this->info("  Warming {$sec}...");
                $this->{$method}();
                $this->info("  ✓ {$sec} warmed");
            }
        }
    }

    private function warmHomepage(): void
    {
        // Clear existing
        Cache::tags(['homepage'])->flush();

        // Warm featured courses
        $this->courseRepo->getFeatured();

        // Warm new courses
        $this->courseRepo->getNew(8);

        // Warm popular categories
        $this->categoryRepo->getPopular();

        // Warm stats
        $this->courseRepo->getStats();

        // Warm testimonials
        Cache::remember('homepage:testimonials', 86400, function () {
            return \\App\\Models\\Testimonial::with('user')
                ->where('is_featured', true)
                ->limit(6)
                ->get();
        });
    }

    private function warmCourses(): void
    {
        // Warm top 100 most popular courses
        $popularCourseIds = \\App\\Models\\Course::where('is_published', true)
            ->orderBy('enrolled_count', 'desc')
            ->limit(100)
            ->pluck('id');

        $progressBar = $this->output->createProgressBar(count($popularCourseIds));

        foreach ($popularCourseIds as $courseId) {
            Cache::tags(["course:{$courseId}"])->flush();
            $this->courseRepo->findWithDetails($courseId);
            $progressBar->advance();
        }

        $progressBar->finish();
        $this->newLine();
    }

    private function warmCategories(): void
    {
        $categories = \\App\\Models\\Category::where('is_active', true)->get();

        $progressBar = $this->output->createProgressBar(count($categories));

        foreach ($categories as $category) {
            Cache::tags(["category:{$category->id}"])->flush();
            $this->courseRepo->getByCategory($category->id);
            $progressBar->advance();
        }

        $progressBar->finish();
        $this->newLine();
    }

    private function warmNavigation(): void
    {
        Cache::tags(['navigation'])->flush();

        // Main navigation
        $this->categoryRepo->getForNavigation();

        // Footer links
        Cache::remember('navigation:footer', 86400, function () {
            return [
                'categories' => \\App\\Models\\Category::where('is_active', true)
                    ->orderBy('name')
                    ->get(['id', 'name', 'slug']),
                'pages' => \\App\\Models\\Page::where('show_in_footer', true)
                    ->get(['title', 'slug']),
            ];
        });
    }

    private function dispatchToQueue(?string $section): void
    {
        $this->info('  Dispatching to queue...');

        \\App\\Jobs\\WarmCacheJob::dispatch($section);

        $this->info('  ✓ Job dispatched');
    }
}

Usage:

# Warm semua cache
php artisan cache:warm

# Warm specific section
php artisan cache:warm --section=homepage

# Warm via queue (non-blocking)
php artisan cache:warm --queue

Queue Job untuk Cache Warming

// app/Jobs/WarmCacheJob.php
namespace App\\Jobs;

use Illuminate\\Bus\\Queueable;
use Illuminate\\Contracts\\Queue\\ShouldQueue;
use Illuminate\\Foundation\\Bus\\Dispatchable;
use Illuminate\\Queue\\InteractsWithQueue;
use Illuminate\\Queue\\SerializesModels;
use Illuminate\\Support\\Facades\\Artisan;

class WarmCacheJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $timeout = 600; // 10 minutes max
    public int $tries = 1;

    public function __construct(
        public ?string $section = null
    ) {}

    public function handle(): void
    {
        $options = $this->section
            ? ['--section' => $this->section]
            : [];

        Artisan::call('cache:warm', $options);
    }
}

// Dispatch after cache clear
class CacheInvalidationService
{
    public function invalidateAll(): void
    {
        Cache::tags(['courses'])->flush();
        Cache::tags(['categories'])->flush();
        Cache::tags(['homepage'])->flush();

        // Warm cache di background
        WarmCacheJob::dispatch()->delay(now()->addSeconds(5));
    }
}

Scheduled Cache Warming

// app/Console/Kernel.php
protected function schedule(Schedule $schedule): void
{
    // Full cache warm setiap hari jam 5 pagi (sebelum traffic naik)
    $schedule->command('cache:warm')
        ->dailyAt('05:00')
        ->withoutOverlapping()
        ->runInBackground();

    // Homepage warm setiap jam
    $schedule->command('cache:warm --section=homepage')
        ->hourly()
        ->withoutOverlapping();

    // Re-warm popular courses setiap 2 jam
    $schedule->call(function () {
        $popularIds = Course::orderBy('enrolled_count', 'desc')
            ->limit(20)
            ->pluck('id');

        foreach ($popularIds as $id) {
            WarmSingleCourseJob::dispatch($id);
        }
    })->everyTwoHours();
}

Lazy Warming (On Cache Miss)

Alternative: Warm in background ketika cache miss terjadi.

class CourseRepository
{
    public function findWithDetails(int $id): ?Course
    {
        $cacheKey = "course:{$id}:full";
        $cached = Cache::tags(['courses', "course:{$id}"])->get($cacheKey);

        if ($cached !== null) {
            return $cached;
        }

        // Cache miss — fetch from DB
        $course = Course::with([
            'instructor.user',
            'category',
            'lessons',
            'reviews' => fn($q) => $q->latest()->limit(10),
        ])->find($id);

        if ($course) {
            // Store in cache
            Cache::tags(['courses', "course:{$id}"])->put(
                $cacheKey,
                $course,
                CacheTTL::COURSE_DETAIL
            );

            // Warm related cache in background
            WarmRelatedCourseDataJob::dispatch($course->id);
        }

        return $course;
    }
}

// Job untuk warm related data
class WarmRelatedCourseDataJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public function __construct(public int $courseId) {}

    public function handle(CourseRepository $repo): void
    {
        $course = Course::find($this->courseId);
        if (!$course) return;

        // Warm related courses
        $repo->getRelatedCourses($course);

        // Warm instructor's other courses
        $repo->getInstructorCourses($course->instructor_id);
    }
}

Deployment Script dengan Cache Warming

#!/bin/bash
# deploy.sh

echo "🚀 Starting deployment..."

# Pull latest code
git pull origin main

# Install dependencies
composer install --no-dev --optimize-autoloader

# Clear all caches
php artisan cache:clear
php artisan config:clear
php artisan route:clear
php artisan view:clear

# Rebuild optimized caches
php artisan config:cache
php artisan route:cache
php artisan view:cache

# Run migrations
php artisan migrate --force

# Warm application cache
echo "🔥 Warming cache..."
php artisan cache:warm

# Restart queue workers
php artisan queue:restart

echo "✅ Deployment complete!"

BuildWithAngga Cache Warming Schedule

CACHE WARMING SCHEDULE:

Daily 05:00 AM (sebelum traffic naik):
├── Full cache warm
├── Homepage, categories, top 100 courses
└── Duration: ~3 minutes

Hourly:
├── Homepage only
└── Duration: ~10 seconds

Every 2 hours:
├── Top 20 most popular courses
└── Duration: ~30 seconds

On Course Update (via Observer):
├── Clear specific course cache
├── Dispatch background job untuk warm
└── Duration: async

On Deployment:
├── Full cache warm setelah cache:clear
└── Duration: ~3 minutes

Dengan setup ini, users hampir tidak pernah hit cold cache. Response time consistent di 100-200ms.

Di bagian selanjutnya, kita akan bahas HTTP Caching dan CDN integration.

Bagian 7: HTTP Caching dan CDN

Sejauh ini kita fokus pada application-level caching. Sekarang kita naik satu layer — caching di level HTTP dan CDN. Ini bisa dramatically reduce load ke server kamu.

HTTP Cache Headers

Browser dan CDN menggunakan HTTP headers untuk determine apakah response bisa di-cache dan berapa lama.

Key Headers:

Cache-Control    → Primary caching directive
ETag             → Content fingerprint untuk validation
Last-Modified    → Timestamp terakhir diubah
Expires          → Legacy, absolute expiration time
Vary             → Headers yang affect caching

Cache-Control Directives

// Public — bisa di-cache oleh browser DAN CDN/proxy
Cache-Control: public, max-age=3600

// Private — hanya browser, tidak CDN (untuk personalized content)
Cache-Control: private, max-age=3600

// No-cache — harus validate dulu ke server, bisa store
Cache-Control: no-cache

// No-store — jangan simpan sama sekali (sensitive data)
Cache-Control: no-store

// Must-revalidate — wajib validate setelah stale
Cache-Control: public, max-age=3600, must-revalidate

// Immutable — tidak akan pernah berubah (versioned assets)
Cache-Control: public, max-age=31536000, immutable

Implementing Cache Headers di Laravel

Middleware untuk Cache Headers:

// app/Http/Middleware/SetCacheHeaders.php
namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;

class SetCacheHeaders
{
    public function handle(Request $request, Closure $next, string $type = 'public', int $maxAge = 3600): Response
    {
        $response = $next($request);

        // Jangan cache untuk authenticated users
        if (auth()->check()) {
            return $response->header('Cache-Control', 'private, no-cache');
        }

        // Jangan cache non-GET requests
        if (!$request->isMethod('GET')) {
            return $response;
        }

        // Jangan cache responses dengan errors
        if ($response->getStatusCode() >= 400) {
            return $response->header('Cache-Control', 'no-store');
        }

        // Set cache headers
        $response->headers->set('Cache-Control', "{$type}, max-age={$maxAge}");
        $response->headers->set('Vary', 'Accept-Encoding, Accept-Language');

        return $response;
    }
}

// Register di Kernel.php
protected $middlewareAliases = [
    'cache.headers' => \\App\\Http\\Middleware\\SetCacheHeaders::class,
];

Usage di Routes:

// routes/web.php

// Homepage — public cache 1 jam
Route::get('/', [HomeController::class, 'index'])
    ->middleware('cache.headers:public,3600');

// Course detail — public cache 30 menit
Route::get('/courses/{slug}', [CourseController::class, 'show'])
    ->middleware('cache.headers:public,1800');

// Static pages — public cache 1 hari
Route::get('/about', [PageController::class, 'about'])
    ->middleware('cache.headers:public,86400');

// User dashboard — private, no cache
Route::get('/dashboard', [DashboardController::class, 'index'])
    ->middleware(['auth', 'cache.headers:private,0']);

ETag Implementation

ETag memungkinkan browser check apakah content berubah tanpa download ulang full response.

// app/Http/Middleware/CheckETag.php
namespace App\\Http\\Middleware;

use Closure;
use Illuminate\\Http\\Request;
use Symfony\\Component\\HttpFoundation\\Response;

class CheckETag
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Hanya untuk GET requests
        if (!$request->isMethod('GET')) {
            return $response;
        }

        // Generate ETag dari content
        $content = $response->getContent();
        $etag = '"' . md5($content) . '"';

        $response->header('ETag', $etag);

        // Check If-None-Match header dari browser
        $requestETag = $request->header('If-None-Match');

        if ($requestETag === $etag) {
            // Content tidak berubah — return 304 Not Modified
            return response('', 304)->header('ETag', $etag);
        }

        return $response;
    }
}

Flow:

Request 1:
Browser → Server: GET /courses/laravel-101
Server → Browser: 200 OK, ETag: "abc123", Content: [full page]
Browser: Store di cache dengan ETag

Request 2 (cache expired tapi want to validate):
Browser → Server: GET /courses/laravel-101, If-None-Match: "abc123"
Server: Check ETag masih sama?
  ├── YES → 304 Not Modified (no body, save bandwidth!)
  └── NO → 200 OK dengan new content dan new ETag

CDN Integration — Cloudflare

CDN meng-cache content di edge servers yang dekat dengan users. Untuk users Indonesia, response datang dari Singapore/Jakarta instead of US/Europe.

Cloudflare Setup:

  1. Add site ke Cloudflare
  2. Update nameservers
  3. Configure caching rules

Page Rules untuk Laravel:

Rule 1: Static Assets (Maximum caching)
────────────────────────────────────
URL Pattern: buildwithangga.com/build/*
Settings:
  - Cache Level: Cache Everything
  - Edge Cache TTL: 1 month
  - Browser Cache TTL: 1 year

Rule 2: Public Pages (untuk guests)
────────────────────────────────────
URL Pattern: buildwithangga.com/courses/*
Settings:
  - Cache Level: Cache Everything
  - Edge Cache TTL: 2 hours
  - Bypass Cache on Cookie: laravel_session

Rule 3: Dynamic/Auth Pages (bypass)
────────────────────────────────────
URL Pattern: buildwithangga.com/dashboard/*
Settings:
  - Cache Level: Bypass

Rule 4: Admin Area (bypass)
────────────────────────────────────
URL Pattern: buildwithangga.com/admin/*
Settings:
  - Cache Level: Bypass

Programmatic Cloudflare Purge

Ketika data berubah, kamu perlu purge CDN cache.

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

use Illuminate\\Support\\Facades\\Http;
use Illuminate\\Support\\Facades\\Log;

class CloudflareCacheService
{
    private string $zoneId;
    private string $apiToken;

    public function __construct()
    {
        $this->zoneId = config('services.cloudflare.zone_id');
        $this->apiToken = config('services.cloudflare.api_token');
    }

    public function purgeUrls(array $urls): bool
    {
        if (empty($this->zoneId) || empty($this->apiToken)) {
            Log::warning('Cloudflare not configured, skipping purge');
            return false;
        }

        // Cloudflare max 30 URLs per request
        $chunks = array_chunk($urls, 30);

        foreach ($chunks as $chunk) {
            $response = Http::withHeaders([
                'Authorization' => "Bearer {$this->apiToken}",
                'Content-Type' => 'application/json',
            ])->post(
                "<https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache>",
                ['files' => $chunk]
            );

            if (!$response->successful()) {
                Log::error('Cloudflare purge failed', [
                    'urls' => $chunk,
                    'response' => $response->json(),
                ]);
                return false;
            }
        }

        Log::info('Cloudflare cache purged', ['urls' => $urls]);
        return true;
    }

    public function purgeCourse(Course $course): void
    {
        $urls = [
            config('app.url') . "/courses/{$course->slug}",
            config('app.url') . "/courses",
            config('app.url') . "/",
        ];

        $this->purgeUrls($urls);
    }

    public function purgeAll(): void
    {
        $response = Http::withHeaders([
            'Authorization' => "Bearer {$this->apiToken}",
            'Content-Type' => 'application/json',
        ])->post(
            "<https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache>",
            ['purge_everything' => true]
        );

        if ($response->successful()) {
            Log::info('Cloudflare full purge completed');
        } else {
            Log::error('Cloudflare full purge failed');
        }
    }
}

Static Assets Caching

Nginx Configuration:

server {
    # Static assets — cache 1 year (versioned via filename)
    location ~* \\.(css|js|jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # HTML — shorter cache
    location ~* \\.(html)$ {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
    }
}


Bagian 8: Multi-Layer Cache Architecture — Complete Implementation

Sekarang kita gabungkan semua yang sudah dipelajari menjadi cohesive multi-layer caching system.

Architecture Overview

┌──────────────────────────────────────────────────────────────┐
│                      USER REQUEST                             │
└───────────────────────────┬──────────────────────────────────┘
                            ▼
┌──────────────────────────────────────────────────────────────┐
│                  LAYER 1: CDN CACHE                          │
│                     (Cloudflare)                             │
│                                                              │
│   ✓ Edge locations dekat users                               │
│   ✓ Static assets: 1 year cache                             │
│   ✓ Public pages: 1-2 hours cache                           │
│                                                              │
│   [HIT]  ───────────────────────────────────────► Response   │
│   [MISS] ─────────────────────┐                              │
└───────────────────────────────┼──────────────────────────────┘
                                ▼
┌──────────────────────────────────────────────────────────────┐
│               LAYER 2: RESPONSE CACHE                        │
│              (Redis + spatie/responsecache)                  │
│                                                              │
│   ✓ Full rendered HTML/JSON                                  │
│   ✓ TTL: 30-60 minutes                                       │
│                                                              │
│   [HIT]  ───────────────────────────────────────► Response   │
│   [MISS] ─────────────────────┐                              │
└───────────────────────────────┼──────────────────────────────┘
                                ▼
┌──────────────────────────────────────────────────────────────┐
│                 LAYER 3: QUERY CACHE                         │
│                       (Redis)                                │
│                                                              │
│   ✓ Database query results                                   │
│   ✓ TTL: 5-120 minutes                                       │
│   ✓ Cache tags untuk invalidation                            │
│                                                              │
│   [HIT]  ───────────────────────────────────────► Build resp │
│   [MISS] ─────────────────────┐                              │
└───────────────────────────────┼──────────────────────────────┘
                                ▼
┌──────────────────────────────────────────────────────────────┐
│                 LAYER 4: DATABASE                            │
│                      (MySQL)                                 │
└──────────────────────────────────────────────────────────────┘

Complete Multi-Layer Service

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

use App\\Models\\Course;
use Illuminate\\Support\\Facades\\Cache;
use Illuminate\\Support\\Facades\\Log;
use Spatie\\ResponseCache\\Facades\\ResponseCache;

class MultiLayerCacheService
{
    public function __construct(
        private CloudflareCacheService $cloudflare,
        private CacheMetricsService $metrics
    ) {}

    public function invalidateCourse(Course $course): void
    {
        Log::info('Invalidating course across all layers', [
            'course_id' => $course->id,
        ]);

        // Layer 3: Query Cache
        Cache::tags(["course:{$course->id}"])->flush();
        Cache::tags(["category:{$course->category_id}"])->flush();

        if ($course->is_featured) {
            Cache::tags(['homepage', 'featured'])->flush();
        }

        // Layer 2: Response Cache
        if (class_exists(ResponseCache::class)) {
            ResponseCache::forget("/courses/{$course->slug}");
            ResponseCache::forget('/courses');
            ResponseCache::forget('/');
        }

        // Layer 1: CDN Cache
        $this->cloudflare->purgeCourse($course);

        // Warm in background
        WarmSingleCourseJob::dispatch($course->id)->delay(now()->addSeconds(5));
    }
}

Prevent Cache Stampede

Ketika cache expired dan banyak requests masuk bersamaan, semua akan hit database. Ini disebut "cache stampede".

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

use Closure;
use Illuminate\\Support\\Facades\\Cache;

class StampedeProtectedCache
{
    public function remember(string $key, array $tags, int $ttl, Closure $callback)
    {
        $value = Cache::tags($tags)->get($key);

        if ($value !== null) {
            return $value;
        }

        // Cache miss — acquire lock
        $lock = Cache::lock("lock:{$key}", 30);

        try {
            if ($lock->get()) {
                // Double-check setelah acquire lock
                $value = Cache::tags($tags)->get($key);
                if ($value !== null) {
                    return $value;
                }

                // Generate value
                $value = $callback();
                Cache::tags($tags)->put($key, $value, $ttl);

                return $value;
            }

            // Wait dan retry
            usleep(100000); // 100ms
            return Cache::tags($tags)->get($key) ?? $callback();

        } finally {
            optional($lock)->release();
        }
    }
}

Cache Metrics Service

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

use Illuminate\\Support\\Facades\\Cache;
use Illuminate\\Support\\Facades\\Redis;

class CacheMetricsService
{
    public function recordHit(string $layer, string $key): void
    {
        Cache::increment("metrics:{$layer}:hits:total");
    }

    public function recordMiss(string $layer, string $key): void
    {
        Cache::increment("metrics:{$layer}:misses:total");
    }

    public function getHitRate(string $layer): float
    {
        $hits = (int) Cache::get("metrics:{$layer}:hits:total", 0);
        $misses = (int) Cache::get("metrics:{$layer}:misses:total", 0);
        $total = $hits + $misses;

        return $total > 0 ? round(($hits / $total) * 100, 2) : 0;
    }

    public function getStats(): array
    {
        return [
            'query_cache' => [
                'hit_rate' => $this->getHitRate('query_cache') . '%',
                'hits' => Cache::get('metrics:query_cache:hits:total', 0),
                'misses' => Cache::get('metrics:query_cache:misses:total', 0),
            ],
            'redis' => [
                'used_memory' => Redis::info()['used_memory_human'] ?? 'N/A',
                'total_keys' => Redis::dbsize(),
            ],
        ];
    }
}


BuildWithAngga Final Results

Setelah implementasi multi-layer caching lengkap:

PERFORMANCE COMPARISON
═══════════════════════════════════════════════════════════════

Metric                    │ Before        │ After         │ Improvement
──────────────────────────┼───────────────┼───────────────┼────────────
Homepage Load Time        │ 2.5 - 4.0s    │ 150 - 200ms   │ 13-20x
Course Detail Page        │ 1.5 - 2.0s    │ 100 - 150ms   │ 10-15x
API Response              │ 800ms         │ 45ms          │ 17x
DB Queries per Request    │ 50 - 100      │ 5 - 10        │ 10x fewer
Server CPU (peak)         │ 90 - 100%     │ 30 - 40%      │ 2.5x lower
Monthly Server Cost       │ $800          │ $300          │ 2.7x cheaper

CACHE HIT RATES
═══════════════════════════════════════════════════════════════

Layer                     │ Hit Rate      │ Avg Response
──────────────────────────┼───────────────┼───────────────
CDN (Cloudflare)          │ 85%           │ 20-50ms
Response Cache            │ 72%           │ 50-100ms
Query Cache               │ 91%           │ 100-200ms
──────────────────────────┼───────────────┼───────────────
Overall                   │ 83%           │ 150ms avg


Best Practices Summary Checklist

MULTI-LAYER CACHING CHECKLIST
═══════════════════════════════════════════════════════════════

LAYER 1 — CDN:
□ Setup page rules untuk static assets
□ Configure bypass untuk authenticated users
□ Implement programmatic purge

LAYER 2 — Response Cache:
□ Install spatie/laravel-responsecache
□ Exclude authenticated routes
□ Set appropriate TTLs

LAYER 3 — Query Cache:
□ Use Redis as cache driver
□ Use cache tags untuk invalidation
□ Setup Model Observers
□ Implement stampede protection

INVALIDATION:
□ Clear all layers (Redis → Response → CDN)
□ Warm cache after invalidation
□ Log for debugging

MONITORING:
□ Track hit rates per layer
□ Monitor Redis memory
□ Alert on low hit rates (<70%)


Common Pitfalls

1. Forgetting to Invalidate Related Cache

// ❌ BAD
$course->update($data);
Cache::forget("course:{$course->id}");
// Category listing masih show old data!

// ✅ GOOD
$course->update($data);
Cache::tags(["course:{$course->id}"])->flush();
Cache::tags(["category:{$course->category_id}"])->flush();

2. Caching Errors

// ❌ BAD — null ter-cache
$course = Cache::remember("course:{$id}", 3600, fn() => Course::find($id));

// ✅ GOOD — handle null
$course = Cache::remember("course:{$id}", 3600, function () use ($id) {
    return Course::findOrFail($id);
});

3. Not Monitoring

// ❌ Deploy tanpa monitoring
// Tidak tau cache bekerja atau tidak

// ✅ Always monitor hit rates dan memory


Closing

Caching adalah optimization dengan ROI tertinggi untuk Laravel applications. Dengan implementasi yang benar:

  • Reduce response time 10-20x — dari detik ke milliseconds
  • Cut server costs 50%+ — butuh lebih sedikit servers
  • Handle more traffic — dengan resources yang sama
  • Improve SEO — Google loves fast sites

Key Takeaways:

  1. Start dengan Redis — Best features dan performance
  2. Use cache tags — Makes invalidation manageable
  3. Implement observers — Auto-invalidate ketika data berubah
  4. Warm your cache — Avoid cold cache problems
  5. Layer your caching — CDN + Response + Query
  6. Monitor everything — Can't improve what you don't measure

Di BuildWithAngga, dengan 900.000+ users, caching adalah core infrastructure. Multi-layer caching memungkinkan kami serve millions of requests dengan cost yang lean.

Start simple dengan query caching, lalu gradually add more layers sesuai kebutuhan.

Selamat ngoding, dan semoga website kamu makin ngebut! 🚀


Artikel ini ditulis oleh Angga Risky Setiawan, Founder BuildWithAngga. Untuk pembelajaran Laravel lebih lanjut, kunjungi buildwithangga.com