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:
- Cache Drivers — File, Redis, Memcached, dan kapan pakai masing-masing
- Basic Operations — put, get, remember, forget, dan patterns yang commonly used
- Query Caching vs Response Caching — Dua approach berbeda untuk caching
- Cache Tags & Invalidation — Strategi untuk keep cache fresh
- Cache Warming — Proactively populate cache
- HTTP Caching & CDN — Browser dan edge caching
- 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:
| Feature | Redis | Memcached |
|---|---|---|
| Speed | Very fast | Very fast |
| Data types | Rich (strings, lists, sets, hashes) | Strings only |
| Persistence | Yes (optional) | No |
| Cache tags | Yes | No |
| Pub/Sub | Yes | No |
| Atomic ops | Yes | Limited |
| Memory efficiency | Good | Better |
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?
- Redis untuk most caches — fast, supports tags
- File untuk fallback dan specific cases
- 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:
- Check apakah
featured_coursesada di cache - Jika HIT → Return cached value, closure tidak di-execute
- 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
| Aspect | Query Caching | Response Caching |
|---|---|---|
| Granularity | Per query | Per page |
| Flexibility | High | Medium |
| Implementation | Manual | Automatic |
| Cache size | Smaller | Larger |
| Hit rate | Varies | High |
| Personalization | Supports | Doesn't support |
| Best for | Dynamic pages | Static 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:
- Add site ke Cloudflare
- Update nameservers
- 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:
- Start dengan Redis — Best features dan performance
- Use cache tags — Makes invalidation manageable
- Implement observers — Auto-invalidate ketika data berubah
- Warm your cache — Avoid cold cache problems
- Layer your caching — CDN + Response + Query
- 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